diff --git a/.github/workflows/sync-upstream.sh b/.github/workflows/sync-upstream.sh index 711452133..97ab91b7d 100755 --- a/.github/workflows/sync-upstream.sh +++ b/.github/workflows/sync-upstream.sh @@ -7,10 +7,16 @@ # sync-upstream.sh rename [--reverse] # just the rename # # This fork differs from upstream in exactly one way: every -# `github.com/google/cel-go` import is rewritten to `github.com/authzed/cel-go`. +# `cel.dev/cel-go` import is rewritten to `github.com/authzed/cel-go`. # Merging upstream directly means both sides edited the same import lines, so it # conflicts on nearly every file upstream touched -- ~77 conflicts, none real. # +# UPSTREAM_PATH is whatever upstream calls itself today; it was +# `github.com/google/cel-go` until v0.32.0 moved it to `cel.dev/cel-go`. A sync +# spanning such a move has to un-rename to the old path (to match the merge +# base) and re-rename from the new one, which means two runs with the constant +# changed in between. +# # So the rename is applied last, and never merged: # # 1. un-rename, making our tree match the upstream commit we last synced from @@ -37,7 +43,7 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" -UPSTREAM_PATH="github.com/google/cel-go" +UPSTREAM_PATH="cel.dev/cel-go" FORK_PATH="github.com/authzed/cel-go" UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-upstream}" @@ -72,8 +78,12 @@ rename() { to="$UPSTREAM_PATH" fi - # -I skips binary files, and git grep only looks at tracked ones. - files="$(git grep -I --name-only --fixed-strings -e "$from" -- . || true)" + # -I skips binary files, and git grep only looks at tracked ones. This + # script is excluded because it names both paths itself: renaming it would + # collapse UPSTREAM_PATH and FORK_PATH onto the same value and break the + # next sync. + files="$(git grep -I --name-only --fixed-strings -e "$from" \ + -- . ':(exclude).github/workflows/sync-upstream.sh' || true)" if [[ -z "$files" ]]; then echo "rename: no occurrences of ${from}; nothing to do" return 0 diff --git a/.github/workflows/tag-submodules.yml b/.github/workflows/tag-submodules.yml new file mode 100644 index 000000000..d2edb0678 --- /dev/null +++ b/.github/workflows/tag-submodules.yml @@ -0,0 +1,42 @@ +name: Tag Go Submodules + +on: + release: + types: [published] + +jobs: + tag-submodules: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@://github.com" + + - name: Auto-Detect and Tag Submodules + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + echo "Finding all subdirectories containing go.mod..." + + # Find all go.mod files, exclude root (./go.mod), and get their directory paths + find . -mindepth 2 -name "go.mod" | while read -r gomod_path; do + # Extract directory path (e.g., ./pkg/submod1/go.mod -> pkg/submod1) + SUBDIR=$(dirname "$gomod_path" | sed 's|^\./||') + + SUB_TAG="${SUBDIR}/${RELEASE_TAG}" + + echo "----------------------------------------" + echo "Found Go module in: $SUBDIR" + echo "Creating tag: $SUB_TAG" + + git tag -a "$SUB_TAG" -m "Release $SUB_TAG via GitHub Action" + git push origin "$SUB_TAG" + done \ No newline at end of file diff --git a/MODULE.bazel b/MODULE.bazel index ba8c23ae9..714224fed 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -37,16 +37,20 @@ bazel_dep( bazel_dep(name = "rules_shell", version = "0.6.1") bazel_dep(name = "rules_license", version = "1.0.0") - # local_path_override( # module_name = "cel-spec", # path = "../cel-spec", # ) bazel_dep( name = "cel-spec", - version = "0.25.1", + version = "0.25.2", repo_name = "dev_cel_expr", ) +git_override( + module_name = "cel-spec", + commit = "ba58ae5007845f3a1279b488cdeb79645ce958bb", + remote = "https://github.com/cel-expr/cel-spec", +) go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk") go_sdk.download(version = "1.23.0") diff --git a/README.md b/README.md index 361eea71a..81a9673f3 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Common Expression Language [![Go Report Card](https://goreportcard.com/badge/github.com/authzed/cel-go)](https://goreportcard.com/report/github.com/authzed/cel-go) -[![GoDoc](https://godoc.org/github.com/authzed/cel-go?status.svg)][6] +[![GoDoc](https://pkg.go.dev/badge/github.com/authzed/cel-go.svg)][6] > [!WARNING] > **On June 16, 2026, this repository will move to > github.com/cel-expr/cel-go!** > > Please update your links and dependencies. See the [pinned -> issue](https://github.com/authzed/cel-go/issues/1329) for details. +> issue](https://github.com/cel-expr/cel-go/issues/1329) for details. The Common Expression Language (CEL) is a non-Turing complete language designed for simplicity, speed, safety, and portability. CEL's C-like [syntax][1] looks @@ -285,9 +285,9 @@ bazel test ... Released under the [Apache License](LICENSE). -[1]: https://github.com/google/cel-spec +[1]: https://github.com/cel-expr/cel-spec [2]: https://groups.google.com/forum/#!forum/cel-go-discuss -[3]: https://github.com/google/cel-cpp -[4]: https://github.com/authzed/cel-go/issues +[3]: https://github.com/cel-expr/cel-cpp +[4]: https://github.com/cel-expr/cel-go/issues [5]: https://bazel.build -[6]: https://godoc.org/github.com/authzed/cel-go +[6]: https://pkg.go.dev/github.com/authzed/cel-go diff --git a/cel/cel_test.go b/cel/cel_test.go index 95b59bdcb..7c82cb9e5 100644 --- a/cel/cel_test.go +++ b/cel/cel_test.go @@ -95,6 +95,90 @@ func Test_ExampleWithBuiltins(t *testing.T) { } } +func TestExtendCheckerParity(t *testing.T) { + // Base environment carrying standard library functions + baseEnv, err := NewEnv( + Variable("baseVar", StringType), + ) + if err != nil { + t.Fatalf("NewEnv() failed: %v", err) + } + + // Extended environment adding child variables (K8s CRD pattern) + extEnv, err := baseEnv.Extend( + Variable("value", StringType), + Variable("oldValue", StringType), + ) + if err != nil { + t.Fatalf("baseEnv.Extend() failed: %v", err) + } + + // Equivalent flat environment created from scratch + flatEnv, err := NewEnv( + Variable("baseVar", StringType), + Variable("value", StringType), + Variable("oldValue", StringType), + ) + if err != nil { + t.Fatalf("flat NewEnv() failed: %v", err) + } + + testCases := []struct { + expr string + vars map[string]any + want ref.Val + }{ + { + expr: `value + " " + oldValue + " " + baseVar`, + vars: map[string]any{"value": "new", "oldValue": "old", "baseVar": "base"}, + want: types.String("new old base"), + }, + { + expr: `size(value) > 0 && [1, 2, 3].exists(x, x > 2)`, + vars: map[string]any{"value": "test"}, + want: types.True, + }, + } + + for _, tc := range testCases { + extAst, extIss := extEnv.Compile(tc.expr) + if extIss.Err() != nil { + t.Fatalf("extEnv.Compile(%q) failed: %v", tc.expr, extIss.Err()) + } + flatAst, flatIss := flatEnv.Compile(tc.expr) + if flatIss.Err() != nil { + t.Fatalf("flatEnv.Compile(%q) failed: %v", tc.expr, flatIss.Err()) + } + + if extAst.OutputType().TypeName() != flatAst.OutputType().TypeName() { + t.Errorf("OutputType mismatch for %q: ext %v, flat %v", tc.expr, extAst.OutputType(), flatAst.OutputType()) + } + + extPrg, err := extEnv.Program(extAst) + if err != nil { + t.Fatalf("extEnv.Program() failed: %v", err) + } + flatPrg, err := flatEnv.Program(flatAst) + if err != nil { + t.Fatalf("flatEnv.Program() failed: %v", err) + } + + extOut, _, err := extPrg.Eval(tc.vars) + if err != nil { + t.Fatalf("extPrg.Eval() failed: %v", err) + } + flatOut, _, err := flatPrg.Eval(tc.vars) + if err != nil { + t.Fatalf("flatPrg.Eval() failed: %v", err) + } + + if extOut.Equal(tc.want) != types.True || flatOut.Equal(tc.want) != types.True { + t.Errorf("Eval result mismatch for %q: ext %v, flat %v, want %v", tc.expr, extOut, flatOut, tc.want) + } + } +} + + func TestCompile(t *testing.T) { prg, err := Compile(`"hello " + name`, Variable("name", StringType)) if err != nil { @@ -1599,6 +1683,64 @@ func TestVariadicLogicalOperators(t *testing.T) { } } +func TestCostTrackingWithStateTracking(t *testing.T) { + // Cost tracking and state tracking install separate observers. Every observer has to see + // every evaluation step, whichever combination of them is configured. + env := testEnv(t, Variable("a", StringType)) + ast, iss := env.Compile(`a.startsWith("x") && a.contains("yz")`) + if iss.Err() != nil { + t.Fatalf("env.Compile() failed: %v", iss.Err()) + } + baseline, _ := evalCostAndState(t, env, ast, CostTracking(nil)) + if baseline == 0 { + t.Fatalf("cost tracking alone reported a cost of 0") + } + tests := []struct { + name string + opts []ProgramOption + wantState bool + wantEqCost bool + }{ + {name: "cost", opts: []ProgramOption{CostTracking(nil)}, wantEqCost: true}, + {name: "cost and state", opts: []ProgramOption{CostTracking(nil), EvalOptions(OptTrackState)}, + wantState: true, wantEqCost: true}, + {name: "cost and exhaustive", opts: []ProgramOption{CostTracking(nil), EvalOptions(OptExhaustiveEval)}, + wantState: true, wantEqCost: true}, + } + for _, tst := range tests { + tc := tst + t.Run(tc.name, func(t *testing.T) { + cost, hasState := evalCostAndState(t, env, ast, tc.opts...) + if tc.wantEqCost && cost != baseline { + t.Errorf("actual cost got %d, wanted %d", cost, baseline) + } + if hasState != tc.wantState { + t.Errorf("state tracked got %t, wanted %t", hasState, tc.wantState) + } + }) + } +} + +// evalCostAndState evaluates the ast and reports the tracked cost along with whether evaluation +// state was recorded. +func evalCostAndState(t *testing.T, env *Env, ast *Ast, opts ...ProgramOption) (uint64, bool) { + t.Helper() + prg, err := env.Program(ast, opts...) + if err != nil { + t.Fatalf("env.Program() failed: %v", err) + } + _, det, err := prg.Eval(map[string]any{"a": "xyz-abcdefghij"}) + if err != nil { + t.Fatalf("prg.Eval() failed: %v", err) + } + cost := det.ActualCost() + if cost == nil { + t.Fatalf("det.ActualCost() returned nil") + } + state := det.State() + return *cost, state != nil && len(state.IDs()) != 0 +} + func TestParseError(t *testing.T) { env := testEnv(t) _, iss := env.Parse("invalid & logical_and") @@ -2504,6 +2646,96 @@ func TestRegexOptimizer(t *testing.T) { } } +func TestRegexProgramSizeLimit(t *testing.T) { + env, err := NewEnv( + Variable("pattern", StringType), + RegexProgramSizeLimit(5), + ) + if err != nil { + t.Fatalf("NewEnv failed: %v", err) + } + + tests := []struct { + name string + expr string + progOpts []ProgramOption + vars any + want ref.Val + compileErr string + progErr string + evalErr string + }{ + { + name: "constant_regex_exceeds_limit_ast_validation", + expr: `"123 abc 456".matches('(a|b)*[0-9]+')`, + compileErr: "regex program size 8 exceeds limit of 5", + }, + { + name: "dynamic_regex_exceeds_limit_runtime", + expr: `"123 abc 456".matches(pattern)`, + vars: map[string]any{"pattern": "(a|b)*[0-9]+"}, + evalErr: "regex program size 8 exceeds limit of 5", + }, + { + name: "dynamic_regex_within_limit", + expr: `"123 abc 456".matches(pattern)`, + vars: map[string]any{"pattern": "[0-9]+"}, + want: types.True, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(tt *testing.T) { + ast, iss := env.Compile(tc.expr) + if tc.compileErr != "" { + if iss.Err() == nil { + tt.Fatalf("env.Compile(%s) succeeded, wanted error %s", tc.expr, tc.compileErr) + } + if !strings.Contains(iss.Err().Error(), tc.compileErr) { + tt.Errorf("got compile error %v, wanted error containing %s", iss.Err(), tc.compileErr) + } + return + } + if iss.Err() != nil { + tt.Fatalf("env.Compile(%s) failed: %v", tc.expr, iss.Err()) + } + prg, err := env.Program(ast, tc.progOpts...) + if tc.progErr != "" { + if err == nil { + tt.Fatalf("env.Program(%s) succeeded, wanted error %s", tc.expr, tc.progErr) + } + if !strings.Contains(err.Error(), tc.progErr) { + tt.Errorf("got program error %v, wanted error containing %s", err, tc.progErr) + } + return + } + if err != nil { + tt.Fatalf("env.Program(%s) failed: %v", tc.expr, err) + } + vars := tc.vars + if vars == nil { + vars = NoVars() + } + res, _, err := prg.Eval(vars) + if tc.evalErr != "" { + if err == nil { + tt.Fatalf("prg.Eval(%s) succeeded, wanted error %s", tc.expr, tc.evalErr) + } + if !strings.Contains(err.Error(), tc.evalErr) { + tt.Errorf("got eval error %v, wanted error containing %s", err, tc.evalErr) + } + return + } + if err != nil { + tt.Fatalf("prg.Eval(%s) failed: %v", tc.expr, err) + } + if res != tc.want { + tt.Errorf("got %v, wanted %v", res, tc.want) + } + }) + } +} + func TestDefaultUTCTimeZoneDisabled(t *testing.T) { testEnvs := []struct { name string @@ -2700,6 +2932,27 @@ func TestDefaultUTCTimeZoneError(t *testing.T) { } } +func TestTimeZoneOffsetOutOfRange(t *testing.T) { + env := testEnv(t, Variable("x", TimestampType)) + vars := map[string]any{"x": time.Unix(7506, 0).UTC()} + // Offsets whose hour or minute component falls outside a signed HH:MM field + // shift the resolved instant, so they must be rejected at evaluation time. + for _, tz := range []string{"+24:00", "-24:00", "+99:00", "-50:30", "+00:99", "+05:-30"} { + out, err := interpret(t, env, `x.getHours('`+tz+`') >= 0`, vars) + if err == nil { + t.Errorf("getHours(%q) got %v, wanted error", tz, out) + } + } + // A boundary offset within the field ranges keeps resolving. + out, err := interpret(t, env, `x.getHours('23:15')`, vars) + if err != nil { + t.Fatalf("getHours('23:15') failed: %v", err) + } + if out.Equal(types.Int(1)) != types.True { + t.Errorf("getHours('23:15') got %v, wanted 1", out) + } +} + func TestParserRecursionLimit(t *testing.T) { testCases := []struct { expr string @@ -3106,6 +3359,18 @@ func TestOptionalValuesEval(t *testing.T) { }, out: types.OptionalOf(types.Int(43)), }, + { + expr: `{0: 10}[?0].optMap(v, v + 1)`, + out: types.OptionalOf(types.Int(11)), + }, + { + expr: `{0: 10}[?0].optMap(a, a + 1).optMap(b, b * 2)`, + out: types.OptionalOf(types.Int(22)), + }, + { + expr: `{0: 10}[?1].optMap(a, a + 1).optMap(b, b * 2)`, + out: types.OptionalNone, + }, { expr: `optional.ofNonZeroValue(z).or(optional.of(10)).value() == 42`, in: map[string]any{ @@ -3724,9 +3989,9 @@ func TestExpressionNodeLimit(t *testing.T) { { name: "chained optMap of various complexity exceeding default limit", expr: "x.optMap(a, [a, a]).optMap(b, {b: b}).optMap(c, c + 1).optMap(d, d + 2).optMap(e, e + 3).optMap(f, f + 4).optMap(g, g + 5).optMap(h, h + 6).optMap(i, i + 7).optMap(j, j + 8).optMap(k, k + 9).optMap(l, l + 10).optMap(m, m + 11).optMap(n, n + 12)", - limit: 0, // default limit 100,000 + limit: 200, // default limit 100,000 expectErr: true, - errSubstring: "expression count exceeds limit of 100000 while expanding macro 'optMap'", + errSubstring: "expression count exceeds limit of 200 while expanding macro 'optMap'", }, { name: "chained optMap with unbounded limit (-1)", @@ -3879,6 +4144,90 @@ func BenchmarkDynamicDispatch(b *testing.B) { }) } +func BenchmarkProgramPlan(b *testing.B) { + b.Run("NewEnv", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := NewEnv( + Variable("ai", IntType), + Variable("ar", MapType(StringType, StringType)), + ) + if err != nil { + b.Fatalf("NewEnv() failed: %v", err) + } + } + }) + + baseEnv, err := NewEnv() + if err != nil { + b.Fatalf("NewEnv() failed: %v", err) + } + + b.Run("ExtendEnv", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := baseEnv.Extend( + Variable("ai", IntType), + Variable("ar", MapType(StringType, StringType)), + ) + if err != nil { + b.Fatalf("baseEnv.Extend() failed: %v", err) + } + } + }) + + env, err := baseEnv.Extend( + Variable("ai", IntType), + Variable("ar", MapType(StringType, StringType)), + ) + if err != nil { + b.Fatalf("Extend() failed: %v", err) + } + astSimple, iss := env.Compile("ai == 20 || ar['foo'] == 'bar'") + if iss.Err() != nil { + b.Fatalf("env.Compile() failed: %v", iss.Err()) + } + astOpt, iss := env.Compile("ai in [10, 20, 30] || 'foo' in ar") + if iss.Err() != nil { + b.Fatalf("env.Compile() failed: %v", iss.Err()) + } + + b.Run("Default", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := env.Program(astSimple) + if err != nil { + b.Fatalf("env.Program() failed: %v", err) + } + } + }) + + b.Run("OptimizeUnneeded", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := env.Program(astSimple, EvalOptions(OptOptimize)) + if err != nil { + b.Fatalf("env.Program() failed: %v", err) + } + } + }) + + b.Run("OptimizeNeeded", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := env.Program(astOpt, EvalOptions(OptOptimize)) + if err != nil { + b.Fatalf("env.Program() failed: %v", err) + } + } + }) +} + func TestAstProgramNilValue(t *testing.T) { var ast *Ast = nil env := testEnv(t) @@ -4030,9 +4379,9 @@ func TestJSONFieldNamesInvalidProvider(t *testing.T) { type wrapperRegistry struct { *types.Registry } - reg, err := types.NewProtoRegistry(types.JSONFieldNames(true)) + reg, err := types.NewRegistry(types.JSONFieldNames(true)) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } wrapped := wrapperRegistry{Registry: reg} _, err = NewEnv(CustomTypeProvider(wrapped), CustomTypeAdapter(reg), JSONFieldNames(true)) diff --git a/cel/decls.go b/cel/decls.go index 64e488d54..b19c25102 100644 --- a/cel/decls.go +++ b/cel/decls.go @@ -220,7 +220,11 @@ func ExcludeOverloads(overloadIDs ...string) OverloadSelector { // FunctionDecls provides one or more fully formed function declarations to be added to the environment. func FunctionDecls(funcs ...*decls.FunctionDecl) EnvOption { return func(e *Env) (*Env, error) { + if len(funcs) == 0 { + return e, nil + } var err error + e.ensureMutableFunctions() for _, fn := range funcs { if existing, found := e.functions[fn.Name()]; found { fn, err = existing.Merge(fn) diff --git a/cel/env.go b/cel/env.go index b738fd141..54743c85f 100644 --- a/cel/env.go +++ b/cel/env.go @@ -17,6 +17,7 @@ package cel import ( "errors" "fmt" + "maps" "math" "slices" "strings" @@ -150,8 +151,24 @@ type Env struct { validators []ASTValidator costOptions []checker.CostOption - funcBindOnce sync.Once - functionBindings []*functions.Overload + // Flags for copy-on-write behavior with env.Extend. + funcsShared bool + featuresShared bool + appliedFeaturesShared bool + limitsShared bool + libsShared bool + + parent *Env + + // sharedDispatcher caches a dispatcher populated with the env's function + // bindings, built once and reused across every Program() constructed from + // this env. It is read-only after construction; each Program layers a thin + // child over it for per-program Functions(). Extended envs reuse the parent's + // dispatcher if functions are unchanged. + sharedDispatcher interpreter.Dispatcher + dispOnce sync.Once + hasAsync bool + dispErr error // Internal parser representation prsr *parser.Parser @@ -365,25 +382,24 @@ func NewEnv(opts ...EnvOption) (*Env, error) { // See the EnvOption helper functions for the options that can be used to configure the // environment. func NewCustomEnv(opts ...EnvOption) (*Env, error) { - registry, err := types.NewProtoRegistry() + registry, err := types.NewRegistry() if err != nil { return nil, err } return (&Env{ - variables: []*decls.VariableDecl{}, - functions: map[string]*decls.FunctionDecl{}, - functionBindings: []*functions.Overload{}, - macros: []parser.Macro{}, - Container: containers.DefaultContainer, - adapter: registry, - provider: registry, - features: map[int]bool{}, - appliedFeatures: map[int]bool{}, - limits: map[limitID]int{}, - libraries: map[string]SingletonLibrary{}, - validators: []ASTValidator{}, - progOpts: []ProgramOption{}, - costOptions: []checker.CostOption{}, + variables: []*decls.VariableDecl{}, + functions: map[string]*decls.FunctionDecl{}, + macros: []parser.Macro{}, + Container: containers.DefaultContainer, + adapter: registry, + provider: registry, + features: map[int]bool{}, + appliedFeatures: map[int]bool{}, + limits: map[limitID]int{}, + libraries: map[string]SingletonLibrary{}, + validators: []ASTValidator{}, + progOpts: []ProgramOption{}, + costOptions: []checker.CostOption{}, }).configure(opts) } @@ -516,34 +532,17 @@ func (e *Env) CompileSource(src Source) (*Ast, *Issues) { // TypeProvider are immutable, or that their underlying implementations are based on the // ref.TypeRegistry which provides a Copy method which will be invoked by this method. func (e *Env) Extend(opts ...EnvOption) (*Env, error) { - chk, chkErr := e.getCheckerOrError() - if chkErr != nil { + if _, chkErr := e.getCheckerOrError(); chkErr != nil { return nil, chkErr } - prsrOptsCopy := make([]parser.Option, len(e.prsrOpts)) - copy(prsrOptsCopy, e.prsrOpts) - - // The type-checker is configured with Declarations. The declarations may either be provided - // as options which have not yet been validated, or may come from a previous checker instance - // whose types have already been validated. - chkOptsCopy := make([]checker.Option, len(e.chkOpts)) - copy(chkOptsCopy, e.chkOpts) - - // Copy the declarations if needed. - if chk != nil { - // If the type-checker has already been instantiated, then the e.declarations have been - // validated within the chk instance. - chkOptsCopy = append(chkOptsCopy, checker.ValidatedDeclarations(chk)) - } - varsCopy := make([]*decls.VariableDecl, len(e.variables)) - copy(varsCopy, e.variables) - - // Copy macros and program options - macsCopy := make([]parser.Macro, len(e.macros)) - progOptsCopy := make([]ProgramOption, len(e.progOpts)) - copy(macsCopy, e.macros) - copy(progOptsCopy, e.progOpts) + prsrOptsCopy := slices.Clone(e.prsrOpts) + chkOptsCopy := slices.Clone(e.chkOpts) + varsCopy := slices.Clone(e.variables) + macsCopy := slices.Clone(e.macros) + progOptsCopy := slices.Clone(e.progOpts) + validatorsCopy := slices.Clone(e.validators) + costOptsCopy := slices.Clone(e.costOptions) // Copy the adapter / provider if they appear to be mutable. adapter := e.adapter @@ -572,53 +571,69 @@ func (e *Env) Extend(opts ...EnvOption) (*Env, error) { adapter = adapterReg.Copy() } - featuresCopy := make(map[int]bool, len(e.features)) - for k, v := range e.features { - featuresCopy[k] = v - } - appliedFeaturesCopy := make(map[int]bool, len(e.appliedFeatures)) - for k, v := range e.appliedFeatures { - appliedFeaturesCopy[k] = v - } - limitsCopy := make(map[limitID]int, len(e.limits)) - for k, v := range e.limits { - limitsCopy[k] = v - } - funcsCopy := make(map[string]*decls.FunctionDecl, len(e.functions)) - for k, v := range e.functions { - funcsCopy[k] = v - } - libsCopy := make(map[string]SingletonLibrary, len(e.libraries)) - for k, v := range e.libraries { - libsCopy[k] = v - } - validatorsCopy := make([]ASTValidator, len(e.validators)) - copy(validatorsCopy, e.validators) - - costOptsCopy := make([]checker.CostOption, len(e.costOptions)) - copy(costOptsCopy, e.costOptions) - ext := &Env{ + parent: e, Container: e.Container, variables: varsCopy, - functions: funcsCopy, + functions: e.functions, macros: macsCopy, contextProto: e.contextProto, progOpts: progOptsCopy, adapter: adapter, - features: featuresCopy, - limits: limitsCopy, - appliedFeatures: appliedFeaturesCopy, - libraries: libsCopy, + features: e.features, + limits: e.limits, + appliedFeatures: e.appliedFeatures, + libraries: e.libraries, validators: validatorsCopy, provider: provider, chkOpts: chkOptsCopy, prsrOpts: prsrOptsCopy, costOptions: costOptsCopy, + // Copy-on-write flags. + funcsShared: true, + featuresShared: true, + limitsShared: true, + appliedFeaturesShared: true, + libsShared: true, } return ext.configure(opts) } +func (e *Env) ensureMutableFunctions() { + if e.funcsShared { + e.functions = maps.Clone(e.functions) + e.funcsShared = false + } +} + +func (e *Env) ensureMutableLibraries() { + if e.libsShared { + e.libraries = maps.Clone(e.libraries) + e.libsShared = false + } +} + +func (e *Env) ensureMutableFeatures() { + if e.featuresShared { + e.features = maps.Clone(e.features) + e.featuresShared = false + } +} + +func (e *Env) ensureMutableAppliedFeatures() { + if e.appliedFeaturesShared { + e.appliedFeatures = maps.Clone(e.appliedFeatures) + e.appliedFeaturesShared = false + } +} + +func (e *Env) ensureMutableLimits() { + if e.limitsShared { + e.limits = maps.Clone(e.limits) + e.limitsShared = false + } +} + // HasFeature checks whether the environment enables the given feature // flag, as enumerated in options.go. func (e *Env) HasFeature(flag int) bool { @@ -649,25 +664,17 @@ func (e *Env) HasFunction(functionName string) bool { // Functions returns a shallow copy of the Functions, keyed by function name, that have been configured in the environment. func (e *Env) Functions() map[string]*decls.FunctionDecl { - shallowCopy := make(map[string]*decls.FunctionDecl, len(e.functions)) - for nm, fn := range e.functions { - shallowCopy[nm] = fn - } - return shallowCopy + return maps.Clone(e.functions) } // Variables returns a shallow copy of the variables associated with the environment. func (e *Env) Variables() []*decls.VariableDecl { - shallowCopy := make([]*decls.VariableDecl, len(e.variables)) - copy(shallowCopy, e.variables) - return shallowCopy + return slices.Clone(e.variables) } // Macros returns a shallow copy of macros associated with the environment. func (e *Env) Macros() []Macro { - shallowCopy := make([]Macro, len(e.macros)) - copy(shallowCopy, e.macros) - return shallowCopy + return slices.Clone(e.macros) } // HasValidator returns whether a specific ASTValidator has been configured in the environment. @@ -680,9 +687,9 @@ func (e *Env) HasValidator(name string) bool { return false } -// Validators returns the set of ASTValidators configured on the environment. +// Validators returns a shallow copy of the set of ASTValidators configured on the environment. func (e *Env) Validators() []ASTValidator { - return e.validators[:] + return slices.Clone(e.validators) } // Parse parses the input expression value `txt` to a Ast and/or a set of Issues. @@ -736,6 +743,41 @@ func (e *Env) PlanProgram(a *celast.AST, opts ...ProgramOption) (Program, error) return newProgram(e, a, optSet) } +func (e *Env) initDispatcher() (interpreter.Dispatcher, bool, error) { + e.dispOnce.Do(func() { + if e.parent != nil && e.funcsShared { + // The dispatcher setup is skipped when the child has mutated the function set. + // As the child function set contains a copy of all parent function declarations + // by virtue of copy on write semantics. + d, hasAsync, err := e.parent.initDispatcher() + e.sharedDispatcher = d + e.hasAsync = hasAsync + e.dispErr = err + return + } + hasAsync := false + var bindings []*functions.Overload + for _, fn := range e.functions { + bs, err := fn.Bindings() + if err != nil { + e.dispErr = err + return + } + for _, b := range bs { + if b.Async != nil { + hasAsync = true + } + } + bindings = append(bindings, bs...) + } + d := interpreter.NewDispatcher() + e.dispErr = d.Add(bindings...) + e.sharedDispatcher = d + e.hasAsync = hasAsync + }) + return e.sharedDispatcher, e.hasAsync, e.dispErr +} + // CELTypeAdapter returns the `types.Adapter` configured for the environment. func (e *Env) CELTypeAdapter() types.Adapter { return e.adapter @@ -861,6 +903,7 @@ func (e *Env) configure(opts []EnvOption) (*Env, error) { // If the default UTC timezone has been disabled, configure the legacy overloads if utcTime, isSet := e.features[featureDefaultUTCTimeZone]; isSet && !utcTime { if !e.appliedFeatures[featureDefaultUTCTimeZone] { + e.ensureMutableAppliedFeatures() e.appliedFeatures[featureDefaultUTCTimeZone] = true e, err = Lib(timeLegacyLibrary{})(e) if err != nil { @@ -933,6 +976,15 @@ func (e *Env) initChecker() (*checker.Env, error) { chkOpts = append(chkOpts, checker.JSONFieldNames(e.HasFeature(featureJSONFieldNames))) + if e.parent != nil && e.funcsShared { + parentChk, err := e.parent.initChecker() + if err != nil { + e.setCheckerOrError(nil, err) + return + } + chkOpts = append(chkOpts, checker.ValidatedDeclarations(parentChk)) + } + ce, err := checker.NewEnv(e.Container, e.provider, chkOpts...) if err != nil { e.setCheckerOrError(nil, err) @@ -945,14 +997,16 @@ func (e *Env) initChecker() (*checker.Env, error) { return } // Add the function declarations which are derived from the FunctionDecl instances. - for _, fn := range e.functions { - if fn.IsDeclarationDisabled() { - continue - } - err = ce.AddFunctions(fn) - if err != nil { - e.setCheckerOrError(nil, err) - return + if e.parent == nil || !e.funcsShared { + for _, fn := range e.functions { + if fn.IsDeclarationDisabled() { + continue + } + err = ce.AddFunctions(fn) + if err != nil { + e.setCheckerOrError(nil, err) + return + } } } // Add function declarations here separately. diff --git a/cel/env_test.go b/cel/env_test.go index 6d7b5b836..8e23f1dd3 100644 --- a/cel/env_test.go +++ b/cel/env_test.go @@ -164,6 +164,37 @@ func TestFormatCELTypeEquivalence(t *testing.T) { } } +func TestEnvExtendDisableDeclaration(t *testing.T) { + baseEnv, err := NewCustomEnv( + Function("foo", + Overload("foo_bool", []*Type{BoolType}, BoolType), + ), + ) + if err != nil { + t.Fatalf("NewCustomEnv() failed: %v", err) + } + _, iss := baseEnv.Compile("foo(true)") + if iss.Err() != nil { + t.Fatalf("baseEnv.Compile(foo(true)) failed: %v", iss.Err()) + } + + childEnv, err := baseEnv.Extend( + Function("foo", + DisableDeclaration(true), + Overload("foo_bool", []*Type{BoolType}, BoolType), + ), + ) + if err != nil { + t.Fatalf("baseEnv.Extend() failed: %v", err) + } + + _, iss = childEnv.Compile("foo(true)") + if iss.Err() == nil { + t.Errorf("childEnv.Compile(foo(true)) succeeded, wanted error") + } +} + + func TestEnvCheckExtendRace(t *testing.T) { t.Parallel() for i := 0; i < 500; i++ { @@ -189,6 +220,116 @@ func TestEnvCheckExtendRace(t *testing.T) { } } +func TestEnvConcurrentExtend(t *testing.T) { + t.Parallel() + baseEnv, err := NewCustomEnv(StdLib()) + if err != nil { + t.Fatalf("NewCustomEnv() failed: %v", err) + } + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + _, err := baseEnv.Extend(Variable(fmt.Sprintf("v%d", id), StringType)) + if err != nil { + t.Errorf("Extend() failed: %v", err) + } + }(i) + } + wg.Wait() +} + +func TestEnvConcurrentExtendAndCompile(t *testing.T) { + t.Parallel() + baseEnv, err := NewCustomEnv(StdLib()) + if err != nil { + t.Fatalf("NewCustomEnv() failed: %v", err) + } + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + varName := fmt.Sprintf("v%d", id) + extEnv, err := baseEnv.Extend(Variable(varName, IntType)) + if err != nil { + t.Errorf("Extend() failed: %v", err) + return + } + ast, iss := extEnv.Compile(fmt.Sprintf("%s > 0", varName)) + if iss.Err() != nil { + t.Errorf("Compile() failed: %v", iss.Err()) + return + } + prg, err := extEnv.Program(ast) + if err != nil { + t.Errorf("Program() failed: %v", err) + return + } + out, _, err := prg.Eval(map[string]any{varName: 10}) + if err != nil { + t.Errorf("Eval() failed: %v", err) + return + } + if out.Value() != true { + t.Errorf("got %v, wanted true", out.Value()) + } + }(i) + } + wg.Wait() +} + +func TestEnvConcurrentExtendWithMutation(t *testing.T) { + t.Parallel() + baseEnv, err := NewCustomEnv(StdLib()) + if err != nil { + t.Fatalf("NewCustomEnv() failed: %v", err) + } + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + fnName := fmt.Sprintf("custom_func_%d", id) + extEnv, err := baseEnv.Extend( + Function(fnName, + Overload(fnName+"_int", []*Type{IntType}, IntType, + UnaryBinding(func(val ref.Val) ref.Val { + return val + }), + ), + ), + ) + if err != nil { + t.Errorf("Extend() failed: %v", err) + return + } + ast, iss := extEnv.Compile(fmt.Sprintf("%s(42) == 42", fnName)) + if iss.Err() != nil { + t.Errorf("Compile() failed: %v", iss.Err()) + return + } + prg, err := extEnv.Program(ast) + if err != nil { + t.Errorf("Program() failed: %v", err) + return + } + out, _, err := prg.Eval(NoVars()) + if err != nil { + t.Errorf("Eval() failed: %v", err) + return + } + if out.Value() != true { + t.Errorf("got %v, wanted true", out.Value()) + } + }(i) + } + wg.Wait() +} + + + func TestEnvPartialVarsError(t *testing.T) { env := testEnv(t) _, err := env.PartialVars(10) @@ -198,9 +339,9 @@ func TestEnvPartialVarsError(t *testing.T) { } func TestTypeProviderInterop(t *testing.T) { - reg, err := types.NewProtoRegistry(types.ProtoTypeDefs(&proto3pb.TestAllTypes{})) + reg, err := types.NewRegistry(&proto3pb.TestAllTypes{}) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } tests := []struct { name string diff --git a/cel/folding.go b/cel/folding.go index 17a7203d6..53d7d9855 100644 --- a/cel/folding.go +++ b/cel/folding.go @@ -175,7 +175,7 @@ func evaluateExpr(ctx *OptimizerContext, a *ast.AST, navigableExpr ast.Navigable } prg, err := ctx.Program(subAST) if err != nil { - return nil, err + return nil, errCannotFold } // Folding will not attempt to call async functions which are all marked as late-bound, // but the presence of such functions requires the use of `ConcurrentEval` in order to @@ -244,6 +244,26 @@ func maybePruneBranches(ctx *OptimizerContext, a *ast.AST, expr ast.NavigableExp } } } + case operators.Add: + if len(args) == 2 && args[0].Kind() == ast.ListKind && args[1].Kind() == ast.ListKind { + leftList := args[0].AsList() + rightList := args[1].AsList() + + elems := make([]ast.Expr, 0, leftList.Size()+rightList.Size()) + elems = append(elems, leftList.Elements()...) + elems = append(elems, rightList.Elements()...) + + optIndices := make([]int32, 0, len(leftList.OptionalIndices())+len(rightList.OptionalIndices())) + optIndices = append(optIndices, leftList.OptionalIndices()...) + offset := int32(leftList.Size()) + for _, idx := range rightList.OptionalIndices() { + optIndices = append(optIndices, offset+idx) + } + + combinedList := ctx.NewList(elems, optIndices) + ctx.UpdateExpr(expr, combinedList) + return true + } } return false } @@ -630,6 +650,11 @@ func constantCallMatcher(e ast.NavigableExpr) bool { } } } + if fnName == operators.Add { + if len(children) == 2 && children[0].Kind() == ast.ListKind && children[1].Kind() == ast.ListKind { + return true + } + } // convert all other calls with constant arguments for _, child := range children { if !constantMatcher(child) { diff --git a/cel/folding_test.go b/cel/folding_test.go index da4cfc622..0ed60e7c4 100644 --- a/cel/folding_test.go +++ b/cel/folding_test.go @@ -45,6 +45,38 @@ func TestConstantFoldingOptimizer(t *testing.T) { expr: `[1, 1 + 2, 1 + (2 + 3)]`, folded: `[1, 3, 6]`, }, + { + expr: `[1, 2] + [3, 4]`, + folded: `[1, 2, 3, 4]`, + }, + { + expr: `[1, ?optional.of(2)] + [3, 4]`, + folded: `[1, 2, 3, 4]`, + }, + { + expr: `[1, ?optional.none()] + [2]`, + folded: `[1, 2]`, + }, + { + expr: `[x, 1] + [2, y]`, + folded: `[x, 1, 2, y]`, + }, + { + expr: `[x, ?optional.of(1)] + [?optional.of(2), y]`, + folded: `[x, 1, 2, y]`, + }, + { + expr: `[1] + [x] + [2]`, + folded: `[1, x, 2]`, + }, + { + expr: `[1] + [?x] + [2]`, + folded: `[1, ?x, 2]`, + }, + { + expr: `[?x, 1] + [2, ?y]`, + folded: `[?x, 1, 2, ?y]`, + }, { expr: `6 in [1, 1 + 2, 1 + (2 + 3)]`, folded: `true`, @@ -516,7 +548,7 @@ func TestConstantFoldingOptimizer(t *testing.T) { }, { expr: `[1] + [x]`, - folded: `[1] + [x]`, + folded: `[1, x]`, }, { diff --git a/cel/library.go b/cel/library.go index 06b589e2b..02b9f2768 100644 --- a/cel/library.go +++ b/cel/library.go @@ -43,6 +43,7 @@ const ( optionalUnwrapFunc = "optional.unwrap" valueFunc = "value" unusedIterVar = "#unused" + targetVar = "@target" ) // Library provides a collection of EnvOption and ProgramOption values used to configure a CEL @@ -97,6 +98,7 @@ func Lib(l Library) EnvOption { if e.HasLibrary(singleton.LibraryName()) { return e, nil } + e.ensureMutableLibraries() e.libraries[singleton.LibraryName()] = singleton } var err error @@ -182,6 +184,9 @@ func (lib *stdLibrary) CompileOptions() []EnvOption { if err = lib.subset.Validate(); err != nil { return nil, err } + if len(funcs) > 0 { + e.ensureMutableFunctions() + } for _, fn := range funcs { existing, found := e.functions[fn.Name()] if found { @@ -609,22 +614,38 @@ func optMap(meh MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, * return nil, meh.NewError(varIdent.ID(), "optMap() variable name must be a simple identifier") } mapExpr := args[1] - return meh.NewCall( + targetIdent := target + if target.Kind() != ast.IdentKind { + targetIdent = meh.NewIdent(targetVar) + } + res := meh.NewCall( operators.Conditional, - meh.NewMemberCall(hasValueFunc, target), + meh.NewMemberCall(hasValueFunc, targetIdent), meh.NewCall(optionalOfFunc, meh.NewComprehension( meh.NewList(), unusedIterVar, varName, - meh.NewMemberCall(valueFunc, meh.Copy(target)), + meh.NewMemberCall(valueFunc, meh.Copy(targetIdent)), meh.NewLiteral(types.False), meh.NewIdent(varName), mapExpr, ), ), meh.NewCall(optionalNoneFunc), - ), nil + ) + if target.Kind() != ast.IdentKind { + return meh.NewComprehension( + meh.NewList(), + unusedIterVar, + targetVar, + target, + meh.NewLiteral(types.False), + meh.NewIdent(targetVar), + res, + ), nil + } + return res, nil } func optFlatMap(meh MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *Error) { @@ -637,20 +658,36 @@ func optFlatMap(meh MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Exp return nil, meh.NewError(varIdent.ID(), "optFlatMap() variable name must be a simple identifier") } mapExpr := args[1] - return meh.NewCall( + targetIdent := target + if target.Kind() != ast.IdentKind { + targetIdent = meh.NewIdent(targetVar) + } + res := meh.NewCall( operators.Conditional, - meh.NewMemberCall(hasValueFunc, target), + meh.NewMemberCall(hasValueFunc, targetIdent), meh.NewComprehension( meh.NewList(), unusedIterVar, varName, - meh.NewMemberCall(valueFunc, meh.Copy(target)), + meh.NewMemberCall(valueFunc, meh.Copy(targetIdent)), meh.NewLiteral(types.False), meh.NewIdent(varName), mapExpr, ), meh.NewCall(optionalNoneFunc), - ), nil + ) + if target.Kind() != ast.IdentKind { + return meh.NewComprehension( + meh.NewList(), + unusedIterVar, + targetVar, + target, + meh.NewLiteral(types.False), + meh.NewIdent(targetVar), + res, + ), nil + } + return res, nil } func optUnwrap(value ref.Val) ref.Val { diff --git a/cel/options.go b/cel/options.go index 65e4da3a8..62529ce2d 100644 --- a/cel/options.go +++ b/cel/options.go @@ -114,6 +114,8 @@ const ( limitMaxASTDepth // The maximum number of expression nodes permitted in parsing (including macro expansion). limitExpressionNodeCount + // The maximum regex program plan size permitted. + limitRegexProgramSize ) // defaultMaxASTDepth mirrors the parser's default maxRecursionDepth (250) and @@ -127,6 +129,7 @@ var limitIDsToNames = map[limitID]string{ limitParseRecursionDepth: "cel.limit.parse_recursion_depth", limitMaxASTDepth: "cel.limit.max_ast_depth", limitExpressionNodeCount: "cel.limit.expression_node_count", + limitRegexProgramSize: "cel.limit.regex_program_size", } func limitNameByID(id limitID) (string, bool) { @@ -917,7 +920,11 @@ func ContextProtoVars(ctx proto.Message, opts ...types.RegistryOption) (Activati } regOpts := []types.RegistryOption{types.ProtoTypeDefs(ctx)} regOpts = append(regOpts, opts...) - reg, err := types.NewProtoRegistry(regOpts...) + var ro []any + for _, opt := range regOpts { + ro = append(ro, opt) + } + reg, err := types.NewRegistry(ro...) if err != nil { return nil, err } @@ -970,6 +977,7 @@ func DefaultUTCTimeZone(enabled bool) EnvOption { // features sets the given feature flags. See list of Feature constants above. func features(flag int, enabled bool) EnvOption { return func(e *Env) (*Env, error) { + e.ensureMutableFeatures() e.features[flag] = enabled return e, nil } @@ -980,6 +988,7 @@ func setLimit(id limitID, limit int) EnvOption { limit = -1 } return func(e *Env) (*Env, error) { + e.ensureMutableLimits() e.limits[id] = limit return e, nil } @@ -1022,6 +1031,25 @@ func ExpressionNestingDepthLimit(limit int) EnvOption { return setLimit(limitMaxASTDepth, limit) } +// RegexProgramSizeLimit caps the maximum regex program plan size permitted for regular expressions. +// A negative or zero value means unbounded. +func RegexProgramSizeLimit(limit int) EnvOption { + return func(e *Env) (*Env, error) { + var err error + e, err = setLimit(limitRegexProgramSize, limit)(e) + if err != nil { + return nil, err + } + if limit > 0 { + e, err = ASTValidators(ValidateRegexProgramSizeLimit(limit))(e) + if err != nil { + return nil, err + } + } + return e, nil + } +} + // EnableHiddenAccumulatorName sets the parser to use the identifier '@result' for accumulators // which is not normally accessible from CEL source. func EnableHiddenAccumulatorName(enabled bool) EnvOption { diff --git a/cel/program.go b/cel/program.go index 5b1958fc2..9ec616bf8 100644 --- a/cel/program.go +++ b/cel/program.go @@ -22,7 +22,8 @@ import ( "github.com/authzed/cel-go/cel/async" "github.com/authzed/cel-go/common/ast" - "github.com/authzed/cel-go/common/functions" + "github.com/authzed/cel-go/common/operators" + "github.com/authzed/cel-go/common/overloads" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" "github.com/authzed/cel-go/interpreter" @@ -200,13 +201,45 @@ type prog struct { asyncMaxConcurrency int } +// scanOptTargets walks the AST once and reports whether the Optimize() +// decorator (needOpt) and the regex-constant compiler (needRegex) have any +// target node present. The conditions are a superset of what each decorator +// acts on, so a false negative — skipping a decorator that would have +// optimized something — is impossible. +func scanOptTargets(root ast.Expr) (needOpt, needRegex bool) { + ast.PostOrderVisit(root, ast.NewExprVisitor(func(e ast.Expr) { + switch e.Kind() { + case ast.ListKind, ast.MapKind: + needOpt = true // maybeBuildListLiteral / maybeBuildMapLiteral + case ast.CallKind: + switch fn := e.AsCall().FunctionName(); { + case fn == overloads.Matches: + needRegex = true + case fn == operators.In || fn == operators.OldIn: + needOpt = true // maybeOptimizeSetMembership + case overloads.IsTypeConversionFunction(fn): + needOpt = true // maybeOptimizeConstUnary + } + } + })) + return +} + // newProgram creates a program instance with an environment, an ast, and an optional list of // ProgramOption values. // // If the program cannot be configured the prog will be nil, with a non-nil error response. func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) { - // Build the dispatcher, interpreter, and default program value. - disp := interpreter.NewDispatcher() + // Build the env's function bindings and shared dispatcher once (pure functions of the + // env). The dispatcher holding the env's function bindings is identical across every + // Program() built from it and read-only during planning — so assemble it once per env + // and layer a thin child over it here for per-program Functions() isolation, rather than + // re-indexing overloads on every Program() call. + sharedDisp, hasAsync, err := e.initDispatcher() + if err != nil { + return nil, err + } + disp := interpreter.ExtendDispatcher(sharedDisp) // Ensure the default attribute factory is set after the adapter and provider are // configured. @@ -216,10 +249,10 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) { dispatcher: disp, costOptions: []interpreter.CostTrackerOption{}, drainStrategy: async.DrainReady(100 * time.Microsecond), + hasAsync: hasAsync, } // Configure the program via the ProgramOption values. - var err error for _, opt := range opts { p, err = opt(p) if err != nil { @@ -227,38 +260,6 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) { } } - e.funcBindOnce.Do(func() { - var bindings []*functions.Overload - e.functionBindings = []*functions.Overload{} - for _, fn := range e.functions { - bindings, err = fn.Bindings() - if err != nil { - return - } - e.functionBindings = append(e.functionBindings, bindings...) - } - }) - if err != nil { - return nil, err - } - - // Add the function bindings created via Function() options. - err = disp.Add(e.functionBindings...) - if err != nil { - return nil, err - } - - // Determine whether the environment declares any asynchronous function. Async is a property of - // the binding, so its presence is known from the environment alone, without inspecting the - // program plan. The synchronous entry points (Eval, ContextEval) reject programs from an env - // with async functions; callers needing synchronous evaluation should use a non-async env. - for _, b := range e.functionBindings { - if b.Async != nil { - p.hasAsync = true - break - } - } - // Set the attribute factory after the options have been set. var attrFactory interpreter.AttributeFactory attrFactorOpts := []interpreter.AttrFactoryOption{ @@ -288,13 +289,29 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) { } // Enable constant folding first. if p.evalOpts&OptOptimize == OptOptimize { - plannerOptions = append(plannerOptions, interpreter.Optimize()) - p.regexOptimizations = append(p.regexOptimizations, interpreter.MatchesRegexOptimization) + // The Optimize() decorator (set-membership, constant list/map literals, + // const type conversions) and the regex-constant compiler each walk + // every planned node. When the AST provably contains no node they can + // act on, adding them is pure overhead — so gate each on a single AST + // scan. The scan condition is a superset of what the decorators touch, + // so a decorator is only skipped when its target node is definitely + // absent and evaluation is never affected (an ungated regex would + // recompile per eval, etc.). + addOptimize, addRegex := scanOptTargets(a.Expr()) + if addOptimize { + plannerOptions = append(plannerOptions, interpreter.Optimize()) + } + if addRegex { + p.regexOptimizations = append(p.regexOptimizations, interpreter.MatchesRegexOptimization) + } } // Enable regex compilation of constants immediately after folding constants. if len(p.regexOptimizations) > 0 { plannerOptions = append(plannerOptions, interpreter.CompileRegexConstants(p.regexOptimizations...)) } + if limit := p.limits[limitRegexProgramSize]; limit > 0 { + plannerOptions = append(plannerOptions, interpreter.RegexProgramSizeLimit(limit)) + } // Enable exhaustive eval, state tracking and cost tracking last since they require a factory. if p.evalOpts&(OptExhaustiveEval|OptTrackState|OptTrackCost) != 0 { diff --git a/cel/program_async_test.go b/cel/program_async_test.go index 15f298b3a..de6de7cb5 100644 --- a/cel/program_async_test.go +++ b/cel/program_async_test.go @@ -188,7 +188,7 @@ func TestConcurrentEval(t *testing.T) { { name: "drain_ready_partial_debounce", expr: `delayed_rpc("a", 1) + delayed_rpc("b", 2) + delayed_rpc("c", 10)`, - opts: []any{cel.ConcurrentDrainStrategy(async.DrainReady(2 * time.Millisecond))}, + opts: []any{cel.ConcurrentDrainStrategy(async.DrainReady(3 * time.Millisecond))}, trackCost: true, wantCost: 15, want: "abc", diff --git a/cel/validator.go b/cel/validator.go index 130a65ded..817e37c91 100644 --- a/cel/validator.go +++ b/cel/validator.go @@ -23,15 +23,17 @@ import ( "github.com/authzed/cel-go/common/ast" "github.com/authzed/cel-go/common/env" "github.com/authzed/cel-go/common/overloads" + "github.com/authzed/cel-go/common/types" ) const ( - durationValidatorName = "cel.validator.duration" - regexValidatorName = "cel.validator.matches" - timestampValidatorName = "cel.validator.timestamp" - homogeneousValidatorName = "cel.validator.homogeneous_literals" - nestingLimitValidatorName = "cel.validator.comprehension_nesting_limit" - bindNestingLimitValidatorName = "cel.validator.bind_nesting_limit" + durationValidatorName = "cel.validator.duration" + regexValidatorName = "cel.validator.matches" + timestampValidatorName = "cel.validator.timestamp" + homogeneousValidatorName = "cel.validator.homogeneous_literals" + nestingLimitValidatorName = "cel.validator.comprehension_nesting_limit" + bindNestingLimitValidatorName = "cel.validator.bind_nesting_limit" + regexProgramSizeLimitValidatorName = "cel.validator.regex_program_size_limit" // HomogeneousAggregateLiteralExemptFunctions is the ValidatorConfig key used to configure // the set of function names which are exempt from homogeneous type checks. The expected type @@ -46,38 +48,25 @@ const ( var ( astValidatorFactories = map[string]ASTValidatorFactory{ nestingLimitValidatorName: func(val *env.Validator) (ASTValidator, error) { - if limit, found := val.ConfigValue("limit"); found { - // In case of protos, config value is of type by google.protobuf.Value, which numeric values are always a double. - if val, isDouble := limit.(float64); isDouble { - if val != float64(int64(val)) { - return nil, fmt.Errorf("invalid validator: %s, limit value is not a whole number: %v", nestingLimitValidatorName, limit) - } - return ValidateComprehensionNestingLimit(int(val)), nil - } - - if val, isInt := limit.(int); isInt { - return ValidateComprehensionNestingLimit(val), nil - } - return nil, fmt.Errorf("invalid validator: %s unsupported limit type: %v", nestingLimitValidatorName, limit) + limit, err := validatorIntConfig(val, "limit") + if err != nil { + return nil, err } - return nil, fmt.Errorf("invalid validator: %s missing limit", nestingLimitValidatorName) + return ValidateComprehensionNestingLimit(limit), nil }, bindNestingLimitValidatorName: func(val *env.Validator) (ASTValidator, error) { - if limit, found := val.ConfigValue("limit"); found { - // In case of protos, config value is of type by google.protobuf.Value, which numeric values are always a double. - if val, isDouble := limit.(float64); isDouble { - if val != float64(int64(val)) { - return nil, fmt.Errorf("invalid validator: %s, limit value is not a whole number: %v", bindNestingLimitValidatorName, limit) - } - return ValidateBindNestingLimit(int(val)), nil - } - - if val, isInt := limit.(int); isInt { - return ValidateBindNestingLimit(val), nil - } - return nil, fmt.Errorf("invalid validator: %s unsupported limit type: %v", bindNestingLimitValidatorName, limit) + limit, err := validatorIntConfig(val, "limit") + if err != nil { + return nil, err } - return nil, fmt.Errorf("invalid validator: %s missing limit", bindNestingLimitValidatorName) + return ValidateBindNestingLimit(limit), nil + }, + regexProgramSizeLimitValidatorName: func(val *env.Validator) (ASTValidator, error) { + limit, err := validatorIntConfig(val, "limit") + if err != nil { + return nil, err + } + return ValidateRegexProgramSizeLimit(limit), nil }, durationValidatorName: func(*env.Validator) (ASTValidator, error) { return ValidateDurationLiterals(), nil @@ -266,6 +255,11 @@ func ValidateBindNestingLimit(limit int) ASTValidator { return bindNestingLimitValidator{limit: limit} } +// ValidateRegexProgramSizeLimit ensures that regex pattern literals do not exceed the specified regex program size limit. +func ValidateRegexProgramSizeLimit(limit int) ASTValidator { + return regexProgramSizeLimitValidator{limit: limit} +} + type argChecker func(env *Env, call, arg ast.Expr) error func newFormatValidator(funcName string, argNum int, check argChecker) formatValidator { @@ -495,6 +489,20 @@ func (v bindNestingLimitValidator) ToConfig() *env.Validator { return env.NewValidator(v.Name()).SetConfig(map[string]any{"limit": v.limit}) } +type regexProgramSizeLimitValidator struct { + limit int +} + +// Name returns the name of the regex program size limit validator. +func (v regexProgramSizeLimitValidator) Name() string { + return regexProgramSizeLimitValidatorName +} + +// ToConfig converts the ASTValidator to an env.Validator specifying the validator name and the limit. +func (v regexProgramSizeLimitValidator) ToConfig() *env.Validator { + return env.NewValidator(v.Name()).SetConfig(map[string]any{"limit": v.limit}) +} + // Validate implements the ASTValidator interface method. func (v bindNestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, iss *Issues) { root := ast.NavigateAST(a) @@ -525,6 +533,47 @@ func (v bindNestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AS } } +func (v regexProgramSizeLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, iss *Issues) { + if v.limit <= 0 { + return + } + root := ast.NavigateAST(a) + callExprs := ast.MatchDescendants(root, ast.KindMatcher(ast.CallKind)) + for _, call := range callExprs { + c := call.AsCall() + fn := c.FunctionName() + if !isRegexFunctionName(fn) { + continue + } + args := c.Args() + var regexArgIndex int + if (fn == overloads.Matches || fn == "matches") && c.Target() != nil { + regexArgIndex = 0 + } else { + regexArgIndex = 1 + } + if len(args) <= regexArgIndex { + continue + } + arg := args[regexArgIndex] + if arg.Kind() != ast.LiteralKind { + continue + } + pattern, ok := arg.AsLiteral().Value().(string) + if !ok { + continue + } + sz, err := types.RegexProgramSize(pattern) + if err != nil { + // Invalid regex literals are handled in a different validator. + continue + } + if sz > v.limit { + iss.ReportErrorAtID(arg.ID(), "regex program size %d exceeds limit of %d", sz, v.limit) + } + } +} + func isEmptyRangeComprehension(e ast.NavigableExpr) bool { if e.Kind() != ast.ComprehensionKind { return false @@ -544,3 +593,25 @@ func isCelBind(e ast.NavigableExpr) bool { loopCond.Kind() == ast.LiteralKind && loopCond.AsLiteral().Value() == false && loopStep.Kind() == ast.IdentKind && loopStep.AsIdent() == compre.AccuVar() } + +func isRegexFunctionName(fn string) bool { + return fn == overloads.Matches || fn == "matches" || fn == "regex.extract" || fn == "regex.extractAll" || fn == "regex.replace" +} + +func validatorIntConfig(val *env.Validator, configKey string) (int, error) { + if limit, found := val.ConfigValue(configKey); found { + // In case of protos, config value is of type google.protobuf.Value, which numeric values are always a double. + if v, isDouble := limit.(float64); isDouble { + if v != float64(int64(v)) { + return 0, fmt.Errorf("invalid validator: %s, %s value is not a whole number: %v", val.Name, configKey, limit) + } + return int(v), nil + } + + if v, isInt := limit.(int); isInt { + return v, nil + } + return 0, fmt.Errorf("invalid validator: %s unsupported %s type: %v", val.Name, configKey, limit) + } + return 0, fmt.Errorf("invalid validator: %s missing %s", val.Name, configKey) +} diff --git a/cel/validator_test.go b/cel/validator_test.go index e6465bfca..048bb7224 100644 --- a/cel/validator_test.go +++ b/cel/validator_test.go @@ -204,6 +204,77 @@ func TestValidateRegexLiterals(t *testing.T) { } } +func TestValidateRegexProgramSizeLimit(t *testing.T) { + opts := []EnvOption{ + Variable("x", types.StringType), + ASTValidators(ValidateRegexProgramSizeLimit(5)), + } + + tests := []struct { + expr string + iss string + }{ + { + expr: `'hello'.matches('el*')`, + }, + { + expr: `'hello'.matches('(a|b)*[0-9]+')`, + iss: ` + ERROR: :1:17: regex program size 8 exceeds limit of 5 + | 'hello'.matches('(a|b)*[0-9]+') + | ................^`, + }, + { + expr: `'hello'.matches(x)`, + }, + } + for _, tst := range tests { + tc := tst + t.Run(tc.expr, func(t *testing.T) { + _, err := Compile(tc.expr, opts...) + if tc.iss != "" { + if err == nil { + t.Fatalf("Compile(%v) returned ast, expected error: %v", tc.expr, tc.iss) + } + if !test.Compare(err.Error(), tc.iss) { + t.Fatalf("Compile(%v) returned %v, expected error: %v", tc.expr, err, tc.iss) + } + return + } + if err != nil { + t.Fatalf("Compile(%v) failed: %v", tc.expr, err) + } + }) + } +} + +func TestValidateRegexProgramSizeLimitToConfig(t *testing.T) { + val := ValidateRegexProgramSizeLimit(5) + cfg := val.(ConfigurableASTValidator).ToConfig() + if cfg.Name != regexProgramSizeLimitValidatorName { + t.Errorf("ToConfig().Name = %s, wanted %s", cfg.Name, regexProgramSizeLimitValidatorName) + } + if limit, ok := cfg.ConfigValue("limit"); !ok || limit != 5 { + t.Errorf("ToConfig().ConfigValue('limit') = %v, wanted 5", limit) + } +} + +func TestValidateRegexProgramSizeLimitFactory(t *testing.T) { + val := ValidateRegexProgramSizeLimit(5) + cfg := val.(ConfigurableASTValidator).ToConfig() + fac, ok := astValidatorFactories[regexProgramSizeLimitValidatorName] + if !ok { + t.Fatalf("missing factory for %s", regexProgramSizeLimitValidatorName) + } + vFromCfg, err := fac(cfg) + if err != nil { + t.Fatalf("fac(cfg) failed: %v", err) + } + if vFromCfg.Name() != regexProgramSizeLimitValidatorName { + t.Errorf("vFromCfg.Name() = %s, wanted %s", vFromCfg.Name(), regexProgramSizeLimitValidatorName) + } +} + func TestValidateHomogeneousAggregateLiterals(t *testing.T) { env, err := NewCustomEnv( Variable("name", StringType), diff --git a/checker/BUILD.bazel b/checker/BUILD.bazel index 145c3e715..1d7f822ed 100644 --- a/checker/BUILD.bazel +++ b/checker/BUILD.bazel @@ -25,6 +25,7 @@ go_library( "//common:go_default_library", "//common/ast:go_default_library", "//common/containers:go_default_library", + "//common/cost:go_default_library", "//common/debug:go_default_library", "//common/decls:go_default_library", "//common/operators:go_default_library", diff --git a/checker/checker_test.go b/checker/checker_test.go index 7e520afe1..953ba035e 100644 --- a/checker/checker_test.go +++ b/checker/checker_test.go @@ -2542,12 +2542,12 @@ func TestCheck(t *testing.T) { t.Fatalf("Unexpected parse errors: %v", errors.ToDisplayString()) } - reg, err := types.NewProtoRegistry( + reg, err := types.NewRegistry( types.JSONFieldNames(tc.env.jsonFieldNames), types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{}), ) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } if tc.env.optionalSyntax { if err := reg.RegisterType(types.OptionalType); err != nil { @@ -2654,9 +2654,9 @@ func BenchmarkCheck(b *testing.B) { if len(errors.GetErrors()) > 0 { b.Fatalf("Unexpected parse errors: %v", errors.ToDisplayString()) } - reg, err := types.NewProtoRegistry(types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{})) + reg, err := types.NewRegistry(types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{})) if err != nil { - b.Fatalf("types.NewProtoRegistry() failed: %v", err) + b.Fatalf("types.NewRegistry() failed: %v", err) } if tc.env.optionalSyntax { if err := reg.RegisterType(types.OptionalType); err != nil { @@ -2723,9 +2723,9 @@ func BenchmarkCheck(b *testing.B) { } func TestAddDuplicateDeclarations(t *testing.T) { - reg, err := types.NewProtoRegistry(types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{})) + reg, err := types.NewRegistry(types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{})) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } env, err := NewEnv(containers.DefaultContainer, reg, CrossTypeNumericComparisons(true)) if err != nil { @@ -2742,9 +2742,9 @@ func TestAddDuplicateDeclarations(t *testing.T) { } func TestAddEquivalentDeclarations(t *testing.T) { - reg, err := types.NewProtoRegistry(types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{})) + reg, err := types.NewRegistry(types.ProtoTypeDefs(&proto2pb.TestAllTypes{}, &proto3pb.TestAllTypes{})) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } env, err := NewEnv(containers.DefaultContainer, reg, CrossTypeNumericComparisons(true)) if err != nil { @@ -2870,3 +2870,50 @@ func testFunction(t testing.TB, name string, opts ...decls.FunctionOpt) *decls.F } return fn } + +func TestVarsInheritance(t *testing.T) { + // Parent environment containing inherited variables 'y' and 'x' + parentEnv, err := NewEnv(containers.DefaultContainer, newTestRegistry(t)) + if err != nil { + t.Fatalf("NewEnv() failed: %v", err) + } + err = parentEnv.AddFunctions(stdlib.Functions()...) + if err != nil { + t.Fatalf("parentEnv.AddFunctions() failed: %v", err) + } + err = parentEnv.AddIdents(decls.NewVariable("z", types.IntType)) + if err != nil { + t.Fatalf("parentEnv.AddIdents() failed: %v", err) + } + + // Child environment inheriting declarations from parentEnv + childEnv, err := NewEnv(containers.DefaultContainer, newTestRegistry(t), ValidatedDeclarations(parentEnv)) + if err != nil { + t.Fatalf("NewEnv(ValidatedDeclarations) failed: %v", err) + } + err = childEnv.AddIdents(decls.NewVariable("y", types.NewListType(types.IntType))) + if err != nil { + t.Fatalf("childEnv.AddIdents() failed: %v", err) + } + + src := common.NewTextSource(`y + [1, 2, 3].filter(x, .z > x)`) + p, err := parser.NewParser(parser.Macros(parser.AllMacros...)) + if err != nil { + t.Fatalf("parser.NewParser() failed: %v", err) + } + parsedAst, iss := p.Parse(src) + if len(iss.GetErrors()) > 0 { + t.Fatalf("parser.Parse() failed: %v", iss.ToDisplayString()) + } + + checkedAst, iss := Check(parsedAst, src, childEnv) + if len(iss.GetErrors()) > 0 { + t.Fatalf("Check() failed: %v", iss.ToDisplayString()) + } + + wantType := types.NewListType(types.IntType) + gotType := checkedAst.GetType(checkedAst.Expr().ID()) + if !gotType.IsExactType(wantType) { + t.Errorf("got result type %v, wanted %v", gotType, wantType) + } +} diff --git a/checker/cost.go b/checker/cost.go index f1aebf298..069e89b73 100644 --- a/checker/cost.go +++ b/checker/cost.go @@ -19,6 +19,7 @@ import ( "github.com/authzed/cel-go/common" "github.com/authzed/cel-go/common/ast" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/overloads" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/parser" @@ -115,8 +116,8 @@ func FixedSizeEstimate(size uint64) SizeEstimate { // If add would result in an uint64 overflow, the result is math.MaxUint64. func (se SizeEstimate) Add(sizeEstimate SizeEstimate) SizeEstimate { return SizeEstimate{ - addUint64NoOverflow(se.Min, sizeEstimate.Min), - addUint64NoOverflow(se.Max, sizeEstimate.Max), + cost.SafeAdd(se.Min, sizeEstimate.Min), + cost.SafeAdd(se.Max, sizeEstimate.Max), } } @@ -124,8 +125,8 @@ func (se SizeEstimate) Add(sizeEstimate SizeEstimate) SizeEstimate { // If multiply would result in an uint64 overflow, the result is math.MaxUint64. func (se SizeEstimate) Multiply(sizeEstimate SizeEstimate) SizeEstimate { return SizeEstimate{ - multiplyUint64NoOverflow(se.Min, sizeEstimate.Min), - multiplyUint64NoOverflow(se.Max, sizeEstimate.Max), + cost.SafeMultiply(se.Min, sizeEstimate.Min), + cost.SafeMultiply(se.Max, sizeEstimate.Max), } } @@ -133,17 +134,17 @@ func (se SizeEstimate) Multiply(sizeEstimate SizeEstimate) SizeEstimate { // nearest integer of the result, rounded up. func (se SizeEstimate) MultiplyByCostFactor(costPerUnit float64) CostEstimate { return CostEstimate{ - multiplyByCostFactor(se.Min, costPerUnit), - multiplyByCostFactor(se.Max, costPerUnit), + cost.SafeMultiplyByFactor(se.Min, costPerUnit), + cost.SafeMultiplyByFactor(se.Max, costPerUnit), } } // MultiplyByCost multiplies by the cost and returns the product. // If multiply would result in an uint64 overflow, the result is math.MaxUint64. -func (se SizeEstimate) MultiplyByCost(cost CostEstimate) CostEstimate { +func (se SizeEstimate) MultiplyByCost(estimate CostEstimate) CostEstimate { return CostEstimate{ - multiplyUint64NoOverflow(se.Min, cost.Min), - multiplyUint64NoOverflow(se.Max, cost.Max), + cost.SafeMultiply(se.Min, estimate.Min), + cost.SafeMultiply(se.Max, estimate.Max), } } @@ -176,25 +177,25 @@ func UnknownCostEstimate() CostEstimate { } // FixedCostEstimate returns a cost with a fixed min and max range. -func FixedCostEstimate(cost uint64) CostEstimate { - return CostEstimate{Min: cost, Max: cost} +func FixedCostEstimate(fixedCost uint64) CostEstimate { + return CostEstimate{Min: fixedCost, Max: fixedCost} } // Add adds the costs and returns the sum. // If add would result in an uint64 overflow for the min or max, the value is set to math.MaxUint64. -func (ce CostEstimate) Add(cost CostEstimate) CostEstimate { +func (ce CostEstimate) Add(estimate CostEstimate) CostEstimate { return CostEstimate{ - Min: addUint64NoOverflow(ce.Min, cost.Min), - Max: addUint64NoOverflow(ce.Max, cost.Max), + Min: cost.SafeAdd(ce.Min, estimate.Min), + Max: cost.SafeAdd(ce.Max, estimate.Max), } } // Multiply multiplies by the cost and returns the product. // If multiply would result in an uint64 overflow, the result is math.MaxUint64. -func (ce CostEstimate) Multiply(cost CostEstimate) CostEstimate { +func (ce CostEstimate) Multiply(estimate CostEstimate) CostEstimate { return CostEstimate{ - Min: multiplyUint64NoOverflow(ce.Min, cost.Min), - Max: multiplyUint64NoOverflow(ce.Max, cost.Max), + Min: cost.SafeMultiply(ce.Min, estimate.Min), + Max: cost.SafeMultiply(ce.Max, estimate.Max), } } @@ -202,8 +203,8 @@ func (ce CostEstimate) Multiply(cost CostEstimate) CostEstimate { // nearest integer of the result, rounded up. func (ce CostEstimate) MultiplyByCostFactor(costPerUnit float64) CostEstimate { return CostEstimate{ - Min: multiplyByCostFactor(ce.Min, costPerUnit), - Max: multiplyByCostFactor(ce.Max, costPerUnit), + Min: cost.SafeMultiplyByFactor(ce.Min, costPerUnit), + Max: cost.SafeMultiplyByFactor(ce.Max, costPerUnit), } } @@ -219,37 +220,6 @@ func (ce CostEstimate) Union(size CostEstimate) CostEstimate { return result } -// addUint64NoOverflow adds non-negative ints. If the result is exceeds math.MaxUint64, math.MaxUint64 -// is returned. -func addUint64NoOverflow(x, y uint64) uint64 { - if y > 0 && x > math.MaxUint64-y { - return math.MaxUint64 - } - return x + y -} - -// multiplyUint64NoOverflow multiplies non-negative ints. If the result is exceeds math.MaxUint64, math.MaxUint64 -// is returned. -func multiplyUint64NoOverflow(x, y uint64) uint64 { - if y != 0 && x > math.MaxUint64/y { - return math.MaxUint64 - } - return x * y -} - -// multiplyByFactor multiplies an integer by a cost factor float and returns the nearest integer value, rounded up. -func multiplyByCostFactor(x uint64, y float64) uint64 { - xFloat := float64(x) - if xFloat > 0 && y > 0 && xFloat > math.MaxUint64/y { - return math.MaxUint64 - } - ceil := math.Ceil(xFloat * y) - if ceil >= doubleTwoTo64 { - return math.MaxUint64 - } - return uint64(ceil) -} - // CostOption configures flags which affect cost computations. type CostOption func(*coster) error @@ -465,32 +435,32 @@ func (c *coster) cost(e ast.Expr) CostEstimate { if e == nil { return CostEstimate{} } - var cost CostEstimate + var estimate CostEstimate switch e.Kind() { case ast.LiteralKind: - cost = constCost + estimate = constCost case ast.IdentKind: - cost = c.costIdent(e) + estimate = c.costIdent(e) case ast.SelectKind: - cost = c.costSelect(e) + estimate = c.costSelect(e) case ast.CallKind: - cost = c.costCall(e) + estimate = c.costCall(e) case ast.ListKind: - cost = c.costCreateList(e) + estimate = c.costCreateList(e) case ast.MapKind: - cost = c.costCreateMap(e) + estimate = c.costCreateMap(e) case ast.StructKind: - cost = c.costCreateStruct(e) + estimate = c.costCreateStruct(e) case ast.ComprehensionKind: if c.isBind(e) { - cost = c.costBind(e) + estimate = c.costBind(e) } else { - cost = c.costComprehension(e) + estimate = c.costComprehension(e) } default: return CostEstimate{} } - return cost + return estimate } func (c *coster) costIdent(e ast.Expr) CostEstimate { @@ -1013,14 +983,14 @@ func computeExprSize(expr ast.Expr) *SizeEstimate { default: return nil } - cost := FixedSizeEstimate(v) - return &cost + size := FixedSizeEstimate(v) + return &size } func computeTypeSize(t *types.Type) *SizeEstimate { if isScalar(t) { - cost := FixedSizeEstimate(1) - return &cost + size := FixedSizeEstimate(1) + return &size } return nil } @@ -1041,8 +1011,6 @@ func isScalar(t *types.Type) bool { } var ( - doubleTwoTo64 = math.Ldexp(1.0, 64) - unknownSizeEstimate = SizeEstimate{Min: 0, Max: math.MaxUint64} unknownCostEstimate = unknownSizeEstimate.MultiplyByCostFactor(1) diff --git a/checker/cost_test.go b/checker/cost_test.go index 9cc2bcbbc..1a343ee4b 100644 --- a/checker/cost_test.go +++ b/checker/cost_test.go @@ -766,9 +766,9 @@ func TestCost(t *testing.T) { if len(errs.GetErrors()) != 0 { t.Fatalf("parser.Parse(%v) failed: %v", tc.expr, errs.ToDisplayString()) } - reg, err := types.NewProtoRegistry(types.ProtoTypeDefs(&proto3pb.TestAllTypes{})) + reg, err := types.NewRegistry(types.ProtoTypeDefs(&proto3pb.TestAllTypes{})) if err != nil { - t.Fatalf("types.NewProtoRegistry(...) failed: %v", err) + t.Fatalf("types.NewRegistry(...) failed: %v", err) } e, err := NewEnv(containers.DefaultContainer, reg) diff --git a/checker/env.go b/checker/env.go index 9d3f5e3b5..6d726c1d6 100644 --- a/checker/env.go +++ b/checker/env.go @@ -97,7 +97,7 @@ func NewEnv(container *containers.Container, provider types.Provider, opts ...Op filteredOverloadIDs = make(map[string]struct{}) } if envOptions.validatedDeclarations != nil { - declarations = envOptions.validatedDeclarations.Copy() + declarations = envOptions.validatedDeclarations.PushInherited() } return &Env{ container: container, diff --git a/checker/env_test.go b/checker/env_test.go index 8048d68ef..b6f035154 100644 --- a/checker/env_test.go +++ b/checker/env_test.go @@ -77,20 +77,6 @@ func BenchmarkNewStdEnv(b *testing.B) { } } -func BenchmarkCopyDeclarations(b *testing.B) { - env, err := NewEnv(containers.DefaultContainer, newTestRegistry(b)) - if err != nil { - b.Fatalf("NewEnv() failed: %v", err) - } - err = env.AddFunctions(stdlib.Functions()...) - if err != nil { - b.Fatalf("env.AddFunctions(stdlib.Functions()...) failed: %v", err) - } - for i := 0; i < b.N; i++ { - env.validatedDeclarations().Copy() - } -} - func newStdEnv(t *testing.T) *Env { t.Helper() env, err := NewEnv(containers.DefaultContainer, newTestRegistry(t)) diff --git a/checker/options.go b/checker/options.go index af714323b..10d3bcbc0 100644 --- a/checker/options.go +++ b/checker/options.go @@ -33,8 +33,8 @@ func CrossTypeNumericComparisons(enabled bool) Option { } } -// ValidatedDeclarations provides a references to validated declarations which will be copied -// into new checker instances. +// ValidatedDeclarations provides a reference to validated declarations which will be inherited +// as a parent scope without copying. func ValidatedDeclarations(env *Env) Option { return func(opts *options) error { opts.validatedDeclarations = env.validatedDeclarations() @@ -49,3 +49,4 @@ func JSONFieldNames(enabled bool) Option { return nil } } + diff --git a/checker/scopes.go b/checker/scopes.go index d91b8c331..afa5dbe4e 100644 --- a/checker/scopes.go +++ b/checker/scopes.go @@ -25,8 +25,9 @@ import ( // Each Groups value is a mapping of names to Decls in the ident and function namespaces. // Lookups are performed such that bindings in inner scopes shadow those in outer scopes. type Scopes struct { - parent *Scopes - scopes *Group + parent *Scopes + inherited *Scopes + scopes *Group } // newScopes creates a new, empty Scopes. @@ -37,19 +38,6 @@ func newScopes() *Scopes { } } -// Copy creates a copy of the current Scopes values, including a copy of its parent if non-nil. -func (s *Scopes) Copy() *Scopes { - cpy := newScopes() - if s == nil { - return cpy - } - if s.parent != nil { - cpy.parent = s.parent.Copy() - } - cpy.scopes = s.scopes.copy() - return cpy -} - // Push creates a new Scopes value which references the current Scope as its parent. func (s *Scopes) Push() *Scopes { return &Scopes{ @@ -58,6 +46,14 @@ func (s *Scopes) Push() *Scopes { } } +// PushInherited creates a new Scopes value which references the current Scope as its inherited parent. +func (s *Scopes) PushInherited() *Scopes { + return &Scopes{ + inherited: s, + scopes: newGroup(), + } +} + // Pop returns the parent Scopes value for the current scope, or the current scope if the parent // is nil. func (s *Scopes) Pop() *Scopes { @@ -74,20 +70,6 @@ func (s *Scopes) AddIdent(decl *decls.VariableDecl) { s.scopes.idents[decl.Name()] = decl } -// FindIdent finds the first ident Decl with a matching name in Scopes, or nil if one cannot be -// found. -// Note: The search is performed from innermost to outermost. -func (s *Scopes) FindIdent(name string) *decls.VariableDecl { - name = strings.TrimPrefix(name, ".") - if ident, found := s.scopes.idents[name]; found { - return ident - } - if s.parent != nil { - return s.parent.FindIdent(name) - } - return nil -} - // FindIdentInScope finds the first ident Decl with a matching name in the current Scopes value, or // nil if one does not exist. // Note: The search is only performed on the current scope and does not search outer scopes. @@ -116,7 +98,13 @@ func (s *Scopes) FindGlobalIdent(name string) *decls.VariableDecl { for scope.parent != nil { scope = scope.parent } - return scope.FindIdentInScope(name) + if ident := scope.FindIdentInScope(name); ident != nil { + return ident + } + if scope.inherited != nil { + return scope.inherited.FindGlobalIdent(name) + } + return nil } // SetFunction adds the function Decl to the current scope. @@ -134,7 +122,14 @@ func (s *Scopes) FindFunction(name string) *decls.FunctionDecl { return fn } if s.parent != nil { - return s.parent.FindFunction(name) + if fn := s.parent.FindFunction(name); fn != nil { + return fn + } + } + if s.inherited != nil { + if fn := s.inherited.FindFunction(name); fn != nil { + return fn + } } return nil } @@ -147,22 +142,6 @@ type Group struct { functions map[string]*decls.FunctionDecl } -// copy creates a new Group instance with a shallow copy of the variables and functions. -// If callers need to mutate the exprpb.Decl definitions for a Function, they should copy-on-write. -func (g *Group) copy() *Group { - cpy := &Group{ - idents: make(map[string]*decls.VariableDecl, len(g.idents)), - functions: make(map[string]*decls.FunctionDecl, len(g.functions)), - } - for n, id := range g.idents { - cpy.idents[n] = id - } - for n, fn := range g.functions { - cpy.functions[n] = fn - } - return cpy -} - // newGroup creates a new Group with empty maps for identifiers and functions. func newGroup() *Group { return &Group{ diff --git a/codelab/codelab.go b/codelab/codelab.go index 131f65ed6..89a8f1983 100644 --- a/codelab/codelab.go +++ b/codelab/codelab.go @@ -54,7 +54,7 @@ func main() { // // Compile, eval, profit! func exercise1() { - fmt.Println("=== Exercise 1: Hello World ===\n") + fmt.Println("=== Exercise 1: Hello World ===") fmt.Println() } @@ -64,7 +64,7 @@ func exercise1() { // Given a `request` of type `google.rpc.context.AttributeContext.Request` // determine whether a specific auth claim is set. func exercise2() { - fmt.Println("=== Exercise 2: Variables ===\n") + fmt.Println("=== Exercise 2: Variables ===") fmt.Println() } @@ -79,7 +79,7 @@ func exercise2() { // sets the appropriate principal and occurs at 12:00 hours. Then evaluate the // request a second time at midnight. Observe the difference in output. func exercise3() { - fmt.Println("=== Exercise 3: Logical AND/OR ===\n") + fmt.Println("=== Exercise 3: Logical AND/OR ===") fmt.Println() } @@ -89,7 +89,7 @@ func exercise3() { // Declare a `contains` member function on map types that returns a boolean // indicating whether the map contains the key-value pair. func exercise4() { - fmt.Println("=== Exercise 4: Customization ===\n") + fmt.Println("=== Exercise 4: Customization ===") fmt.Println() } @@ -98,7 +98,7 @@ func exercise4() { // // Given the input `now`, construct a JWT with an expiry of 5 minutes. func exercise5() { - fmt.Println("=== Exercise 5: Building JSON ===\n") + fmt.Println("=== Exercise 5: Building JSON ===") fmt.Println() } @@ -109,7 +109,7 @@ func exercise5() { // `google.rpc.context.AttributeContext.Request` with the `time` and `auth` // fields populated according to the go/api-attributes specification. func exercise6() { - fmt.Println("=== Exercise 6: Building Protos ===\n") + fmt.Println("=== Exercise 6: Building Protos ===") fmt.Println() } @@ -120,7 +120,7 @@ func exercise6() { // with the `group` prefix, and ensure that all group-like keys have list // values containing only strings that end with '@acme.co`. func exercise7() { - fmt.Println("=== Exercise 7: Macros ===\n") + fmt.Println("=== Exercise 7: Macros ===") fmt.Println() } @@ -134,7 +134,7 @@ func exercise7() { // Also, turn on the homogeneous aggregate literals flag to disable // heterogeneous list and map literals. func exercise8() { - fmt.Println("=== Exercise 8: Tuning ===\n") + fmt.Println("=== Exercise 8: Tuning ===") fmt.Println() } diff --git a/codelab/go.mod b/codelab/go.mod index fe627fead..f664521b4 100644 --- a/codelab/go.mod +++ b/codelab/go.mod @@ -1,21 +1,19 @@ module github.com/authzed/cel-go/codelab -go 1.22.0 - -toolchain go1.22.5 +go 1.23.0 require ( - github.com/golang/glog v1.2.4 github.com/authzed/cel-go v0.21.0 + github.com/golang/glog v1.2.4 google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 - google.golang.org/protobuf v1.34.2 + google.golang.org/protobuf v1.36.10 ) require ( - cel.dev/expr v0.22.1 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/stoewer/go-strcase v1.2.0 // indirect - golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect ) diff --git a/codelab/go.sum b/codelab/go.sum index 8dbce6c33..596a97228 100644 --- a/codelab/go.sum +++ b/codelab/go.sum @@ -1,32 +1,22 @@ -cel.dev/expr v0.22.1 h1:xoFEsNh972Yzey8N9TCPx2nDvMN7TMhQEzxLuj/iRrI= -cel.dev/expr v0.22.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= -golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/codelab/solution/codelab.go b/codelab/solution/codelab.go index d1edf25c0..5a758ded7 100644 --- a/codelab/solution/codelab.go +++ b/codelab/solution/codelab.go @@ -54,7 +54,7 @@ func main() { // // Compile, eval, profit! func exercise1() { - fmt.Println("=== Exercise 1: Hello World ===\n") + fmt.Println("=== Exercise 1: Hello World ===") // Create the standard environment. env, err := cel.NewEnv() if err != nil { @@ -93,7 +93,7 @@ func exercise1() { // Given a `request` of type `google.rpc.context.AttributeContext.Request` // determine whether a specific auth claim is set. func exercise2() { - fmt.Println("=== Exercise 2: Variables ===\n") + fmt.Println("=== Exercise 2: Variables ===") // Construct a standard environment that accepts 'request' as input and uses // the google.rpc.context.AttributeContext.Request type. env, err := cel.NewEnv( @@ -125,7 +125,7 @@ func exercise2() { // sets the appropriate principal and occurs at 12:00 hours. Then evaluate the // request a second time at midnight. Observe the difference in output. func exercise3() { - fmt.Println("=== Exercise 3: Logical AND/OR ===\n") + fmt.Println("=== Exercise 3: Logical AND/OR ===") env, _ := cel.NewEnv( cel.Types(&rpcpb.AttributeContext_Request{}), cel.Variable("request", @@ -154,7 +154,7 @@ func exercise3() { // Declare a `contains` member function on map types that returns a boolean // indicating whether the map contains the key-value pair. func exercise4() { - fmt.Println("=== Exercise 4: Customization ===\n") + fmt.Println("=== Exercise 4: Customization ===") // Determine whether an optional claim is set to the proper value. The custom // map.contains(key, value) function is used as an alternative to: // key in map && map[key] == value @@ -200,7 +200,7 @@ func exercise4() { // // Given the input `now`, construct a JWT with an expiry of 5 minutes. func exercise5() { - fmt.Println("=== Exercise 5: Building JSON ===\n") + fmt.Println("=== Exercise 5: Building JSON ===") // Note the quoted keys in the CEL map literal. For proto messages the // field names are unquoted as they represent well-defined identifiers. env, _ := cel.NewEnv( @@ -238,7 +238,7 @@ func exercise5() { // `google.rpc.context.AttributeContext.Request` with the `time` and `auth` // fields populated according to the go/api-attributes specification. func exercise6() { - fmt.Println("=== Exercise 6: Building Protos ===\n") + fmt.Println("=== Exercise 6: Building Protos ===") // Construct an environment and indicate that the container for all references // within the expression is `google.rpc.context.AttributeContext`. @@ -305,7 +305,7 @@ func exercise6() { // with the `group` prefix, and ensure that all group-like keys have list // values containing only strings that end with '@acme.co`. func exercise7() { - fmt.Println("=== Exercise 7: Macros ===\n") + fmt.Println("=== Exercise 7: Macros ===") env, _ := cel.NewEnv(cel.Variable("jwt", cel.DynType)) ast := compile(env, `jwt.extra_claims.exists(c, c.startsWith('group')) @@ -341,7 +341,7 @@ func exercise7() { // Turn on the optimization, exhaustive eval, and state tracking // `cel.ProgramOption` flags to see the impact on evaluation behavior. func exercise8() { - fmt.Println("=== Exercise 8: Tuning ===\n") + fmt.Println("=== Exercise 8: Tuning ===") // Declare the `x` and 'y' variables as input into the expression. env, _ := cel.NewEnv( cel.Variable("x", cel.IntType), diff --git a/common/ast/navigable_test.go b/common/ast/navigable_test.go index d6ea22c28..1f2b03239 100644 --- a/common/ast/navigable_test.go +++ b/common/ast/navigable_test.go @@ -669,9 +669,13 @@ func mustTypeCheck(t testing.TB, expr string, opts ...any) *ast.AST { func newTestRegistry(t testing.TB, opts ...types.RegistryOption) *types.Registry { t.Helper() - reg, err := types.NewProtoRegistry(opts...) + var o []any + for _, opt := range opts { + o = append(o, opt) + } + reg, err := types.NewRegistry(o...) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } return reg } diff --git a/common/cost/BUILD.bazel b/common/cost/BUILD.bazel new file mode 100644 index 000000000..932dd3d6f --- /dev/null +++ b/common/cost/BUILD.bazel @@ -0,0 +1,25 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +package( + default_visibility = ["//visibility:public"], + licenses = ["notice"], # Apache 2.0 +) + +go_library( + name = "go_default_library", + srcs = [ + "cost.go", + ], + importpath = "github.com/authzed/cel-go/common/cost", +) + +go_test( + name = "go_default_test", + size = "small", + srcs = [ + "cost_test.go", + ], + embed = [ + ":go_default_library", + ], +) diff --git a/common/cost/cost.go b/common/cost/cost.go new file mode 100644 index 000000000..1a81a88e5 --- /dev/null +++ b/common/cost/cost.go @@ -0,0 +1,78 @@ +// 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 +// +// 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 cost provides the saturating arithmetic shared by cost estimation and cost tracking. +// +// Costs and sizes are unsigned 64-bit values where math.MaxUint64 doubles as the representation +// of an unbounded, or unknown, quantity. Every operation in this package saturates at +// math.MaxUint64 rather than wrapping so that an unbounded input remains unbounded through any +// sequence of operations. +package cost + +import "math" + +// maxUint64AsFloat is the smallest float64 value greater than math.MaxUint64. +// +// Conversion of a float64 to a uint64 is undefined when the value is out of range, so float +// results are compared against this bound before conversion. +var maxUint64AsFloat = math.Ldexp(1.0, 64) + +// SafeAdd returns the sum of the input values, saturating at math.MaxUint64. +func SafeAdd(x, y uint64, rest ...uint64) uint64 { + sum := x + if y > 0 && sum > math.MaxUint64-y { + return math.MaxUint64 + } + sum += y + for _, r := range rest { + if r > 0 && sum > math.MaxUint64-r { + return math.MaxUint64 + } + sum += r + } + return sum +} + +// SafeMultiply returns the product of the input values, saturating at math.MaxUint64. +func SafeMultiply(x, y uint64) uint64 { + if y != 0 && x > math.MaxUint64/y { + return math.MaxUint64 + } + return x * y +} + +// SafeMultiplyByFactor multiplies a value by a cost factor and returns the nearest integer +// value, rounded up, saturating at math.MaxUint64. +func SafeMultiplyByFactor(x uint64, factor float64) uint64 { + xFloat := float64(x) + if xFloat > 0 && factor > 0 && xFloat > math.MaxUint64/factor { + return math.MaxUint64 + } + return SafeCeil(xFloat * factor) +} + +// SafeCeil returns the smallest integer value greater than or equal to the input, saturating at +// math.MaxUint64 and flooring at zero. +// +// Negative and NaN inputs return zero. +func SafeCeil(x float64) uint64 { + if math.IsNaN(x) || x <= 0 { + return 0 + } + ceil := math.Ceil(x) + if ceil >= maxUint64AsFloat { + return math.MaxUint64 + } + return uint64(ceil) +} diff --git a/common/cost/cost_test.go b/common/cost/cost_test.go new file mode 100644 index 000000000..83a3fd5f8 --- /dev/null +++ b/common/cost/cost_test.go @@ -0,0 +1,118 @@ +// 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 +// +// 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 cost + +import ( + "math" + "testing" +) + +func TestSafeAdd(t *testing.T) { + tests := []struct { + name string + x, y uint64 + rest []uint64 + want uint64 + }{ + {name: "zero", x: 0, y: 0, want: 0}, + {name: "simple", x: 2, y: 3, want: 5}, + {name: "variadic", x: 1, y: 2, rest: []uint64{3, 4}, want: 10}, + {name: "max plus zero", x: math.MaxUint64, y: 0, want: math.MaxUint64}, + {name: "overflow", x: math.MaxUint64, y: 1, want: math.MaxUint64}, + {name: "overflow near max", x: math.MaxUint64 - 5, y: 10, want: math.MaxUint64}, + {name: "overflow in rest", x: 1, y: 2, rest: []uint64{math.MaxUint64}, want: math.MaxUint64}, + {name: "saturated stays saturated", x: math.MaxUint64, y: math.MaxUint64, + rest: []uint64{math.MaxUint64}, want: math.MaxUint64}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := SafeAdd(tc.x, tc.y, tc.rest...); got != tc.want { + t.Errorf("SafeAdd(%d, %d, %v) got %d, want %d", tc.x, tc.y, tc.rest, got, tc.want) + } + }) + } +} + +func TestSafeMultiply(t *testing.T) { + tests := []struct { + name string + x, y uint64 + want uint64 + }{ + {name: "zero", x: 0, y: 0, want: 0}, + {name: "max by zero", x: math.MaxUint64, y: 0, want: 0}, + {name: "simple", x: 3, y: 4, want: 12}, + {name: "max by one", x: math.MaxUint64, y: 1, want: math.MaxUint64}, + {name: "overflow", x: math.MaxUint64, y: 2, want: math.MaxUint64}, + {name: "overflow squared", x: math.MaxUint32, y: math.MaxUint32 * 2, want: math.MaxUint64}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := SafeMultiply(tc.x, tc.y); got != tc.want { + t.Errorf("SafeMultiply(%d, %d) got %d, want %d", tc.x, tc.y, got, tc.want) + } + }) + } +} + +func TestSafeMultiplyByFactor(t *testing.T) { + tests := []struct { + name string + x uint64 + factor float64 + want uint64 + }{ + {name: "zero value", x: 0, factor: 0.1, want: 0}, + {name: "zero factor", x: 100, factor: 0, want: 0}, + {name: "rounds up", x: 15, factor: 0.1, want: 2}, + {name: "exact", x: 10, factor: 0.1, want: 1}, + {name: "whole factor", x: 10, factor: 3, want: 30}, + {name: "max saturates", x: math.MaxUint64, factor: 2, want: math.MaxUint64}, + {name: "max scaled down", x: math.MaxUint64, factor: 0.1, want: 1844674407370955264}, + {name: "negative factor", x: 10, factor: -1, want: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := SafeMultiplyByFactor(tc.x, tc.factor); got != tc.want { + t.Errorf("SafeMultiplyByFactor(%d, %f) got %d, want %d", tc.x, tc.factor, got, tc.want) + } + }) + } +} + +func TestSafeCeil(t *testing.T) { + tests := []struct { + name string + x float64 + want uint64 + }{ + {name: "zero", x: 0, want: 0}, + {name: "negative", x: -1.5, want: 0}, + {name: "nan", x: math.NaN(), want: 0}, + {name: "fraction", x: 0.1, want: 1}, + {name: "rounds up", x: 2.5, want: 3}, + {name: "whole", x: 3.0, want: 3}, + {name: "infinity", x: math.Inf(1), want: math.MaxUint64}, + {name: "out of range", x: math.Ldexp(1.0, 64), want: math.MaxUint64}, + {name: "largest in range", x: math.Ldexp(1.0, 63), want: 1 << 63}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := SafeCeil(tc.x); got != tc.want { + t.Errorf("SafeCeil(%f) got %d, want %d", tc.x, got, tc.want) + } + }) + } +} diff --git a/common/decls/decls.go b/common/decls/decls.go index 56c7ea8c4..ebbe6f119 100644 --- a/common/decls/decls.go +++ b/common/decls/decls.go @@ -78,6 +78,9 @@ type FunctionDecl struct { // overloadOrdinals indicates the order in which the overload was declared. overloadOrdinals []string + + // overloadDecls caches the slice of overloads in declaration order. + overloadDecls []*OverloadDecl } type declarationState int @@ -151,7 +154,8 @@ func (f *FunctionDecl) Merge(other *FunctionDecl) (*FunctionDecl, error) { name: f.Name(), overloads: make(map[string]*OverloadDecl, len(f.overloads)), singleton: f.singleton, - overloadOrdinals: make([]string, len(f.overloads)), + overloadOrdinals: make([]string, len(f.overloadOrdinals)), + overloadDecls: make([]*OverloadDecl, len(f.overloadDecls)), // if one function is expecting type-guards and the other is not, then they // must not be disabled. disableTypeGuards: f.disableTypeGuards && other.disableTypeGuards, @@ -170,6 +174,7 @@ func (f *FunctionDecl) Merge(other *FunctionDecl) (*FunctionDecl, error) { } // baseline copy of the overloads and their ordinals copy(merged.overloadOrdinals, f.overloadOrdinals) + copy(merged.overloadDecls, f.overloadDecls) for oID, o := range f.overloads { merged.overloads[oID] = o } @@ -232,11 +237,13 @@ func (f *FunctionDecl) Subset(selector OverloadSelector) *FunctionDecl { } overloads := make(map[string]*OverloadDecl) overloadOrdinals := make([]string, 0, len(f.overloadOrdinals)) + overloadDecls := make([]*OverloadDecl, 0, len(f.overloadDecls)) for _, oID := range f.overloadOrdinals { overload := f.overloads[oID] if selector(overload) { overloads[oID] = overload overloadOrdinals = append(overloadOrdinals, oID) + overloadDecls = append(overloadDecls, overload) } } if len(overloads) == 0 { @@ -250,6 +257,7 @@ func (f *FunctionDecl) Subset(selector OverloadSelector) *FunctionDecl { disableTypeGuards: f.disableTypeGuards, state: f.state, overloadOrdinals: overloadOrdinals, + overloadDecls: overloadDecls, } return subset } @@ -273,6 +281,12 @@ func (f *FunctionDecl) AddOverload(overload *OverloadDecl) error { // Allow redefinition of an overload implementation so long as the signatures match. if overload.HasBinding() { f.overloads[oID] = overload + for i, decl := range f.overloadDecls { + if decl.ID() == oID { + f.overloadDecls[i] = overload + break + } + } } // Allow redefinition of the doc string. if len(overload.doc) != 0 && o.doc != overload.doc { @@ -288,20 +302,16 @@ func (f *FunctionDecl) AddOverload(overload *OverloadDecl) error { } f.overloadOrdinals = append(f.overloadOrdinals, overload.ID()) f.overloads[overload.ID()] = overload + f.overloadDecls = append(f.overloadDecls, overload) return nil } // OverloadDecls returns the overload declarations in the order in which they were declared. func (f *FunctionDecl) OverloadDecls() []*OverloadDecl { - var emptySet []*OverloadDecl if f == nil { - return emptySet - } - overloads := make([]*OverloadDecl, 0, len(f.overloads)) - for _, oID := range f.overloadOrdinals { - overloads = append(overloads, f.overloads[oID]) + return nil } - return overloads + return f.overloadDecls } // HasSingletonBinding indicates whether the function has a singleton binding definition. diff --git a/common/env/env_test.go b/common/env/env_test.go index 6ee20367a..8cc83edac 100644 --- a/common/env/env_test.go +++ b/common/env/env_test.go @@ -748,9 +748,9 @@ func TestVariableAsCELVariable(t *testing.T) { }, } - tp, err := types.NewProtoRegistry() + tp, err := types.NewRegistry() if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } tp.RegisterType(types.NewOpaqueType("set", types.NewTypeParamType("T"))) for _, tst := range tests { @@ -936,9 +936,9 @@ func TestFunctionAsCELFunction(t *testing.T) { types.NewTypeParamType("T"))), }, } - tp, err := types.NewProtoRegistry() + tp, err := types.NewRegistry() if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } tp.RegisterType(types.NewOpaqueType("set", types.NewTypeParamType("T"))) for _, tst := range tests { @@ -1047,9 +1047,9 @@ func TestTypeDescAsCELTypeErrors(t *testing.T) { want: errors.New("undefined type"), }, } - tp, err := types.NewProtoRegistry() + tp, err := types.NewRegistry() if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } tp.RegisterType(types.NewOpaqueType("set", types.NewTypeParamType("T"))) for _, tst := range tests { @@ -1534,7 +1534,7 @@ func unmarshalYAML(t *testing.T, data []byte) *Config { t.Helper() config, err := ConfigFromYAML(data) if err != nil { - t.Fatalf("ConfigFromYaml(%q) failed: %v", string(data), err) + t.Fatalf("ConfigFromYAML(%q) failed: %v", string(data), err) } return config } diff --git a/common/env/io.go b/common/env/io.go index ec126f9ce..9ef6334da 100644 --- a/common/env/io.go +++ b/common/env/io.go @@ -234,11 +234,15 @@ func (p *typeDescParser) parseTypeParamIdent() (string, error) { } func (p *typeDescParser) skipWhitespace() { - for p.pos < p.length && p.text[p.pos] == ' ' { + for p.pos < p.length && isWhitespace(p.text[p.pos]) { p.pos++ } } +func isWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + func isAlpha(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } diff --git a/common/env/io_test.go b/common/env/io_test.go index b4068e9b3..2aeeecc76 100644 --- a/common/env/io_test.go +++ b/common/env/io_test.go @@ -51,6 +51,18 @@ func TestParseTypeDesc(t *testing.T) { "map>", NewTypeDesc("map", NewTypeDesc("int"), NewTypeDesc("list", NewTypeDesc("string"))), }, + { + "list<\n\tint\n>", + NewTypeDesc("list", NewTypeDesc("int")), + }, + { + "map<\r\n string,\r\n list\r\n>", + NewTypeDesc("map", NewTypeDesc("string"), NewTypeDesc("list", NewTypeDesc("string"))), + }, + { + "map", + NewTypeDesc("map", NewTypeDesc("string"), NewTypeDesc("int")), + }, } for _, tc := range tcs { t.Run(tc.text, func(t *testing.T) { @@ -235,6 +247,35 @@ functions: return: type_name: V is_type_param: true +`, + }, + { + name: "multiline and tab whitespace in types", + yamlIn: `name: user_env +variables: + - name: user_lookup_table + type: >- + map< + string, + list + > + - name: permissions + type: "map" +`, + yamlOut: `name: user_env +variables: + - name: user_lookup_table + type_name: map + params: + - type_name: string + - type_name: list + params: + - type_name: string + - name: permissions + type_name: map + params: + - type_name: string + - type_name: int `, }, } diff --git a/common/stdlib/standard.go b/common/stdlib/standard.go index e2ea809ce..335be8abc 100644 --- a/common/stdlib/standard.go +++ b/common/stdlib/standard.go @@ -16,6 +16,7 @@ package stdlib import ( + "fmt" "math" "strconv" "strings" @@ -1053,7 +1054,7 @@ func inTimeZone(ts, tz ref.Val) (time.Time, error) { } // If the input is not the name of a timezone (for example, 'US/Central'), it should be a numerical offset from UTC - // in the format ^(+|-)(0[0-9]|1[0-4]):[0-5][0-9]$. The numerical input is parsed in terms of hours and minutes. + // in the format ^(+|-)([01]\d|2[0-3]):[0-5][0-9]$. The numerical input is parsed in terms of hours and minutes. hr, err := strconv.Atoi(string(val[0:ind])) if err != nil { return time.Time{}, err @@ -1062,6 +1063,12 @@ func inTimeZone(ts, tz ref.Val) (time.Time, error) { if err != nil { return time.Time{}, err } + if hr < -23 || hr > 23 { + return time.Time{}, fmt.Errorf("timezone offset hours out of range [-23, 23]: %s", val) + } + if min < 0 || min > 59 { + return time.Time{}, fmt.Errorf("timezone offset minutes out of range [0, 59]: %s", val) + } var offset int if string(val[0]) == "-" { offset = hr*60 - min diff --git a/common/types/BUILD.bazel b/common/types/BUILD.bazel index 89ac60e37..c13cacf59 100644 --- a/common/types/BUILD.bazel +++ b/common/types/BUILD.bazel @@ -8,6 +8,7 @@ package( go_library( name = "go_default_library", srcs = [ + "aggregate_sizer.go", "any_value.go", "bool.go", "bytes.go", @@ -21,12 +22,16 @@ go_library( "format.go", "list.go", "map.go", + "native.go", "null.go", "object.go", "optional.go", "overflow.go", "provider.go", + "regex.go", + "size_calc.go", "string.go", + "struct.go", "timestamp.go", "types.go", "uint.go", @@ -67,10 +72,13 @@ go_test( "json_struct_test.go", "list_test.go", "map_test.go", + "native_test.go", "null_test.go", "object_test.go", "optional_test.go", "provider_test.go", + "regex_test.go", + "size_calc_test.go", "string_test.go", "timestamp_test.go", "types_test.go", @@ -80,13 +88,19 @@ go_test( ], embed = [":go_default_library"], deps = [ + "//cel:go_default_library", + "//common/types/pb:go_default_library", "//common/types/ref:go_default_library", + "//common/types/traits:go_default_library", + "//ext:go_default_library", "//test:go_default_library", "//test/proto3pb:test_all_types_go_proto", "@org_golang_google_genproto_googleapis_api//expr/v1alpha1:go_default_library", "@org_golang_google_protobuf//encoding/protojson:go_default_library", + "@org_golang_google_protobuf//proto:go_default_library", "@org_golang_google_protobuf//types/known/anypb:go_default_library", "@org_golang_google_protobuf//types/known/durationpb:go_default_library", + "@org_golang_google_protobuf//types/known/structpb:go_default_library", "@org_golang_google_protobuf//types/known/timestamppb:go_default_library", ], ) diff --git a/common/types/aggregate_sizer.go b/common/types/aggregate_sizer.go new file mode 100644 index 000000000..d56b8bc4d --- /dev/null +++ b/common/types/aggregate_sizer.go @@ -0,0 +1,43 @@ +// 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 +// +// 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 types + +// AggregateSizer calculates the recursive element size of values. +type AggregateSizer interface { + // AggregateSize returns the size of the input value, if known. + // Otherwise, a unit size of 1 is returned. + AggregateSize(val any) uint32 +} + +// AggregateSizeVisitor interface for ref.Val implementations capable of returning +// their total recursive element count. +type AggregateSizeVisitor interface { + // AggregateSize returns the total count of nested atomic elements (capped at math.MaxUint32). + AggregateSize(sizer AggregateSizer) uint32 +} + +// Helper for computing aggregate sizes of traits.Foldable types. +type foldableAggregateSizer struct { + sizer AggregateSizer + total uint32 +} + +// FoldEntry implements the traits.FoldEntry interface method and counts the aggregate size +// keys and values. +func (f *foldableAggregateSizer) FoldEntry(k, v any) bool { + f.total = safeAddUint32(f.total, f.sizer.AggregateSize(k)) + f.total = safeAddUint32(f.total, f.sizer.AggregateSize(v)) + return true +} diff --git a/common/types/list.go b/common/types/list.go index b6452c295..cef411f6b 100644 --- a/common/types/list.go +++ b/common/types/list.go @@ -18,6 +18,7 @@ import ( "fmt" "reflect" "strings" + "sync/atomic" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -110,14 +111,13 @@ func NewMutableList(adapter Adapter) traits.MutableLister { type baseList struct { Adapter value any - - // size indicates the number of elements within the list. - // Since objects are immutable the size of a list is static. - size int - - // get returns a value at the specified integer index. - // The index is guaranteed to be checked against the list index range. - get func(int) any + size int + // aggSize memoizes the aggregate size computed by the first completed sizing of this + // list. Accessed atomically since immutable lists may be shared across concurrent + // evaluations; zero means not yet computed. See the SizeCalculator documentation for + // the memoization contract. + aggSize uint32 + get func(int) any } // Add implements the traits.Adder interface method. @@ -269,6 +269,21 @@ func (l *baseList) Size() ref.Val { return Int(l.size) } +// AggregateSize implements the AggregateSizeVisitor interface method. +func (l *baseList) AggregateSize(sizer AggregateSizer) uint32 { + if sz := atomic.LoadUint32(&l.aggSize); sz != 0 { + return sz + } + total := uint32(1) + for i := range l.size { + total = safeAddUint32(total, sizer.AggregateSize(l.get(i))) + } + if cacheableAggregateSize(sizer) { + atomic.StoreUint32(&l.aggSize, total) + } + return total +} + // Type implements the ref.Val interface method. func (l *baseList) Type() ref.Type { return ListType @@ -322,11 +337,13 @@ func (l *mutableList) Add(other ref.Val) ref.Val { case *mutableList: l.mutableValues = append(l.mutableValues, otherList.mutableValues...) l.size += len(otherList.mutableValues) + atomic.StoreUint32(&l.aggSize, 0) case traits.Lister: for i := IntZero; i < otherList.Size().(Int); i++ { l.size++ l.mutableValues = append(l.mutableValues, otherList.Get(i)) } + atomic.StoreUint32(&l.aggSize, 0) default: return MaybeNoSuchOverloadErr(otherList) } @@ -480,6 +497,11 @@ func (l *concatList) Size() ref.Val { return l.cachedSize } +// AggregateSize implements the AggregateSizeVisitor interface method. +func (l *concatList) AggregateSize(sizer AggregateSizer) uint32 { + return safeAddUint32(sizer.AggregateSize(l.prevList), sizer.AggregateSize(l.nextList)) +} + // String converts the concatenated list to a human-readable string. func (l *concatList) String() string { var sb strings.Builder diff --git a/common/types/list_test.go b/common/types/list_test.go index 8f8168fbe..756bd6020 100644 --- a/common/types/list_test.go +++ b/common/types/list_test.go @@ -930,3 +930,67 @@ func TestConcatListSizeCached(t *testing.T) { } } } + +func TestListCalculateSize(t *testing.T) { + adapter := DefaultTypeAdapter + + // List literal: [1, [3, 4], [[7, 8], [9, 10]]] + l1 := NewRefValList(adapter, []ref.Val{Int(3), Int(4)}) + l2_1 := NewRefValList(adapter, []ref.Val{Int(7), Int(8)}) + l2_2 := NewRefValList(adapter, []ref.Val{Int(9), Int(10)}) + l2 := NewRefValList(adapter, []ref.Val{l2_1, l2_2}) + nested := NewRefValList(adapter, []ref.Val{Int(1), l1, l2}) + + tests := []struct { + name string + val ref.Val + want uint32 + }{ + { + name: "empty_list", + val: NewRefValList(adapter, []ref.Val{}), + want: 1, + }, + { + name: "flat_list", + val: l1, + want: 3, + }, + { + name: "nested_list", + val: nested, + want: 12, + }, + { + name: "concat_list", + val: l1.Add(l2_1), + want: 6, + }, + { + name: "string_list", + val: NewStringList(adapter, []string{"hello", "world"}), + want: 3, // 1 (container) + 1 ("hello" unit) + 1 ("world" unit) = 3 + }, + { + name: "dynamic_list", + val: NewDynamicList(adapter, []any{int64(1), []int64{3, 4}}), + want: 5, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sizer, ok := tc.val.(AggregateSizeVisitor) + if !ok { + t.Fatalf("expected AggregateSizeVisitor implementation for %T", tc.val) + } + if got := sizer.AggregateSize(NewSizeCalculator()); got != tc.want { + t.Errorf("got aggregate size %d, want %d", got, tc.want) + } + // Caching check (memoized aggSize) + if got := sizer.AggregateSize(NewSizeCalculator()); got != tc.want { + t.Errorf("memoized AggregateSize() got %d, want %d", got, tc.want) + } + }) + } +} diff --git a/common/types/map.go b/common/types/map.go index 22207458a..45761874f 100644 --- a/common/types/map.go +++ b/common/types/map.go @@ -19,6 +19,7 @@ import ( "reflect" "sort" "strings" + "sync/atomic" "unicode" "google.golang.org/protobuf/proto" @@ -142,8 +143,13 @@ type baseMap struct { // value is the native Go value upon which the map type operators. value any - // size is the number of entries in the map. size int + + // aggSize memoizes the aggregate size computed by the first completed sizing of this + // map. Accessed atomically since immutable maps may be shared across concurrent + // evaluations; zero means not yet computed. See the SizeCalculator documentation for + // the memoization contract. + aggSize uint32 } // Contains implements the traits.Container interface method. @@ -303,6 +309,19 @@ func (m *baseMap) Size() ref.Val { return Int(m.size) } +// AggregateSize implements the AggregateSizeVisitor interface method. +func (m *baseMap) AggregateSize(sizer AggregateSizer) uint32 { + if sz := atomic.LoadUint32(&m.aggSize); sz != 0 { + return sz + } + f := foldableAggregateSizer{sizer: sizer, total: 1} + m.Fold(&f) + if cacheableAggregateSize(sizer) { + atomic.StoreUint32(&m.aggSize, f.total) + } + return f.total +} + // String converts the map into a human-readable string. func (m *baseMap) String() string { var sb strings.Builder @@ -380,6 +399,8 @@ func (m *mutableMap) Insert(k, v ref.Val) ref.Val { return NewErr("insert failed: key %v already exists", k) } m.mutableValues[k] = v + m.size++ + atomic.StoreUint32(&m.aggSize, 0) return m } @@ -909,6 +930,20 @@ func (m *protoMap) Size() ref.Val { return Int(m.value.Len()) } +// AggregateSize implements the AggregateSizeVisitor interface method. +func (m *protoMap) AggregateSize(sizer AggregateSizer) uint32 { + if m.value == nil { + return 0 + } + total := uint32(1) + m.value.Range(func(k protoreflect.MapKey, v protoreflect.Value) bool { + total = safeAddUint32(total, sizer.AggregateSize(k)) + total = safeAddUint32(total, sizer.AggregateSize(v)) + return true + }) + return total +} + // Type implements the ref.Val interface method. func (m *protoMap) Type() ref.Type { return MapType diff --git a/common/types/map_test.go b/common/types/map_test.go index e33673797..eaacb12e8 100644 --- a/common/types/map_test.go +++ b/common/types/map_test.go @@ -1252,3 +1252,98 @@ func (m proxyLegacyMap) Iterator() traits.Iterator { func (m proxyLegacyMap) Size() ref.Val { return m.proxy.Size() } + +func TestMapCalculateSize(t *testing.T) { + adapter := DefaultTypeAdapter + + // Setup helper data + l := NewRefValList(adapter, []ref.Val{Int(2), Int(3)}) + refValMap := NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("a"): Int(1), + String("b"): l, + }) + + ifaceMap := NewStringInterfaceMap(adapter, map[string]any{ + "a": int64(1), + "b": []any{int64(2), int64(3)}, + }) + + mutMap := NewMutableMap(adapter, map[ref.Val]ref.Val{ + String("a"): Int(1), + String("b"): l, + }) + // Initial evaluation before insert to test aggSize reset + _ = mutMap.(AggregateSizeVisitor).AggregateSize(NewSizeCalculator()) + mutMap.Insert(String("c"), Int(4)) + + reg, err := NewRegistry(&proto3pb.TestAllTypes{}) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + msg := &proto3pb.TestAllTypes{ + MapStringString: map[string]string{ + "a": "b", + "c": "d", + }, + } + pbMsg := reg.NativeToValue(msg).(traits.Indexer) + pm := pbMsg.Get(String("map_string_string")).(traits.Mapper) + + tests := []struct { + name string + val ref.Val + want uint32 + }{ + { + name: "empty_ref_val_map", + val: NewRefValMap(adapter, map[ref.Val]ref.Val{}), + want: 1, + }, + { + name: "ref_val_map_nested", + val: refValMap, + want: 7, + }, + { + name: "string_interface_map", + val: ifaceMap, + want: 7, + }, + { + name: "string_string_map", + val: NewStringStringMap(adapter, map[string]string{"k1": "v1", "k2": "v2"}), + want: 5, // 1 (container) + 4 (single-unit keys and values) = 5 + }, + { + name: "mutable_map_after_insert", + val: mutMap, + want: 9, + }, + { + name: "proto_map", + val: pm, + want: 5, + }, + { + name: "nil_proto_map", + val: &protoMap{}, + want: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sizer, ok := tc.val.(AggregateSizeVisitor) + if !ok { + t.Fatalf("expected AggregateSizeVisitor implementation for %T", tc.val) + } + if got := sizer.AggregateSize(NewSizeCalculator()); got != tc.want { + t.Errorf("got aggregate size %d, want %d", got, tc.want) + } + // Caching check (memoized aggSize) + if got := sizer.AggregateSize(NewSizeCalculator()); got != tc.want { + t.Errorf("memoized AggregateSize() got %d, want %d", got, tc.want) + } + }) + } +} diff --git a/common/types/native.go b/common/types/native.go new file mode 100644 index 000000000..871f377cb --- /dev/null +++ b/common/types/native.go @@ -0,0 +1,600 @@ +// Copyright 2022 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 +// +// 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 me. +// limitations under the License. + +package types + +import ( + "errors" + "fmt" + "reflect" + "strings" + "time" + + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/authzed/cel-go/common/types/ref" + "github.com/authzed/cel-go/common/types/traits" + + structpb "google.golang.org/protobuf/types/known/structpb" +) + +var ( + nativeObjTraitMask = traits.FieldTesterType | traits.IndexerType + jsonValueType = reflect.TypeFor[*structpb.Value]() + jsonStructType = reflect.TypeFor[*structpb.Struct]() + + pbMsgInterfaceType = reflect.TypeFor[protoreflect.ProtoMessage]() + refValType = reflect.TypeFor[ref.Val]() + timestampType = reflect.TypeFor[time.Time]() + durationType = reflect.TypeFor[time.Duration]() + + errDuplicatedFieldName = errors.New("field name already exists in struct") +) + +// NewNativeType constructs a NativeType instance for a Go struct reflect.Type. +func NewNativeType(rawType reflect.Type, opts ...NativeTypeOption) (*NativeType, error) { + tpOptions := NativeTypeOptions{} + for _, opt := range opts { + if err := opt(&tpOptions); err != nil { + return nil, err + } + } + return newNativeType(rawType, tpOptions.fieldNameHandler) +} + +// NativeTypesFieldNameHandler is a handler for mapping a reflect.StructField to a CEL field name. +// This can be used to override the default Go struct field to CEL field name mapping. +type NativeTypesFieldNameHandler = func(field reflect.StructField) string + +// NativeTypeOptions holds options for native types. +type NativeTypeOptions struct { + fieldNameHandler NativeTypesFieldNameHandler +} + +// NativeTypeOption is a functional option for configuring handling of native types. +type NativeTypeOption func(*NativeTypeOptions) error + +// ParseStructTags configures if native types field names should be overridable by CEL struct tags. +// This is equivalent to ParseStructTag("cel"). +func ParseStructTags(enabled bool) NativeTypeOption { + if enabled { + return ParseStructTag("cel") + } + return ParseStructField(nil) +} + +// ParseStructTag configures the struct tag to parse. The 0th item in the tag is used as the name of the CEL field. +func ParseStructTag(tag string) NativeTypeOption { + return ParseStructField(fieldNameByTag(tag)) +} + +// ParseStructField configures how to parse Go struct fields. It can be used to customize struct field parsing. +func ParseStructField(handler NativeTypesFieldNameHandler) NativeTypeOption { + return func(opts *NativeTypeOptions) error { + opts.fieldNameHandler = handler + return nil + } +} + +func fieldNameByTag(structTagToParse string) func(field reflect.StructField) string { + return func(field reflect.StructField) string { + tag, found := field.Tag.Lookup(structTagToParse) + if found { + splits := strings.Split(tag, ",") + if len(splits) > 0 { + name := splits[0] + return name + } + } + return field.Name + } +} + +func isSkippedFieldName(name string) bool { + return name == "" || name == "-" +} + +// NativeType represents a CEL struct type descriptor generated from a native Go struct. +type NativeType struct { + typeName string + refType reflect.Type + fieldsByName map[string]reflect.StructField +} + +// ReflectType implements StructTypeDescriptor. +func (t *NativeType) ReflectType() reflect.Type { + return t.refType +} + +// Adapt implements StructTypeDescriptor. +func (t *NativeType) Adapt(adapter Adapter, value any) ref.Val { + if value == nil { + return NullValue + } + refVal := reflect.ValueOf(value) + if refVal.Kind() == reflect.Ptr { + if refVal.IsNil() { + return NullValue + } + refVal = refVal.Elem() + } + return &nativeObj{ + Adapter: adapter, + val: value, + valType: t, + refValue: refVal, + } +} + +// ConvertToNative implements ref.Val.ConvertToNative. +func (t *NativeType) ConvertToNative(typeDesc reflect.Type) (any, error) { + return nil, fmt.Errorf("type conversion error for type to '%v'", typeDesc) +} + +// ConvertToType implements ref.Val.ConvertToType. +func (t *NativeType) ConvertToType(typeVal ref.Type) ref.Val { + switch typeVal { + case TypeType: + return TypeType + } + return NewErr("type conversion error from '%s' to '%s'", TypeType, typeVal) +} + +// Equal returns true if both type names are equal to each other. +func (t *NativeType) Equal(other ref.Val) ref.Val { + otherType, ok := other.(ref.Type) + return Bool(ok && t.TypeName() == otherType.TypeName()) +} + +// HasTrait implements the ref.Type interface method. +func (t *NativeType) HasTrait(trait int) bool { + return nativeObjTraitMask&trait == trait +} + +// String implements the fmt.Stringer interface method. +func (t *NativeType) String() string { + return t.typeName +} + +// Type implements the ref.Val interface method. +func (t *NativeType) Type() ref.Type { + return TypeType +} + +// TypeName implements the ref.Type interface method. +func (t *NativeType) TypeName() string { + return t.typeName +} + +// Value implements the ref.Val interface method. +func (t *NativeType) Value() any { + return t.typeName +} + +func (t *NativeType) hasField(fieldName string) (reflect.StructField, bool) { + f, found := t.fieldsByName[fieldName] + if !found { + return reflect.StructField{}, false + } + return f, true +} + +// FieldNames provides the list of field names for this type. +func (t *NativeType) FieldNames() []string { + fields := make([]string, 0, len(t.fieldsByName)) + for fieldName := range t.fieldsByName { + fields = append(fields, fieldName) + } + return fields +} + +// FindFieldType looks up a field by name and provides the type and accessors. +func (t *NativeType) FindFieldType(fieldName string) (*FieldType, bool) { + refField, found := t.hasField(fieldName) + if !found { + return nil, false + } + celType, ok := convertToCelType(refField.Type) + if !ok { + return nil, false + } + return &FieldType{ + Type: celType, + IsSet: func(obj any) bool { + refVal := reflect.Indirect(reflect.ValueOf(obj)) + refFieldVal := safeGetFieldByIndex(refVal, refField.Index) + return refFieldVal.IsValid() && !refFieldVal.IsZero() + }, + GetFrom: func(obj any) (any, error) { + refVal := reflect.Indirect(reflect.ValueOf(obj)) + refFieldVal := safeGetFieldByIndex(refVal, refField.Index) + return getFieldValue(refFieldVal), nil + }, + }, true +} + +// NewValue constructs a new native Go struct instance populated with given field values. +func (t *NativeType) NewValue(adapter Adapter, fields map[string]ref.Val) ref.Val { + refPtr := reflect.New(t.refType) + refVal := refPtr.Elem() + for fieldName, val := range fields { + refFieldDef, isDefined := t.hasField(fieldName) + if !isDefined { + return NewErr("no such field: %s", fieldName) + } + fieldVal, err := val.ConvertToNative(refFieldDef.Type) + if err != nil { + return NewErrFromString(err.Error()) + } + refField := safeSetFieldByIndex(refVal, refFieldDef.Index) + if !refField.IsValid() { + return NewErr("cannot set field: %s", fieldName) + } + refField.Set(reflect.ValueOf(fieldVal)) + } + return adapter.NativeToValue(refPtr.Interface()) +} + +type nativeObj struct { + Adapter + val any + valType *NativeType + refValue reflect.Value +} + +func (o *nativeObj) ConvertToNative(typeDesc reflect.Type) (any, error) { + if o.refValue.Type() == typeDesc { + if reflect.TypeOf(o.val) == typeDesc { + return o.val, nil + } + return o.refValue.Interface(), nil + } + if typeDesc.Kind() == reflect.Pointer && o.refValue.Type() == typeDesc.Elem() { + if reflect.TypeOf(o.val) == typeDesc { + return o.val, nil + } + ptr := reflect.New(o.refValue.Type()) + ptr.Elem().Set(o.refValue) + return ptr.Interface(), nil + } + switch typeDesc { + case jsonValueType: + jsonStruct, err := o.ConvertToNative(jsonStructType) + if err != nil { + return nil, err + } + return structpb.NewStructValue(jsonStruct.(*structpb.Struct)), nil + case jsonStructType: + refVal := reflect.Indirect(o.refValue) + fields := make(map[string]*structpb.Value, refVal.NumField()) + for fieldName, fieldType := range o.valType.fieldsByName { + fieldValue := safeGetFieldByIndex(refVal, fieldType.Index) + if !fieldValue.IsValid() || fieldValue.IsZero() { + continue + } + fieldCELVal := o.NativeToValue(fieldValue.Interface()) + fieldJSONVal, err := fieldCELVal.ConvertToNative(jsonValueType) + if err != nil { + return nil, err + } + fields[fieldName] = fieldJSONVal.(*structpb.Value) + } + return &structpb.Struct{Fields: fields}, nil + } + return nil, fmt.Errorf("type conversion error from '%v' to '%v'", o.Type(), typeDesc) +} + +func (o *nativeObj) ConvertToType(typeVal ref.Type) ref.Val { + switch typeVal { + case TypeType: + return o.valType + default: + if typeVal.TypeName() == o.valType.typeName { + return o + } + } + return NewErr("type conversion error from '%s' to '%s'", o.Type(), typeVal) +} + +func (o *nativeObj) Equal(other ref.Val) ref.Val { + otherNtv, ok := other.(*nativeObj) + if !ok { + return False + } + val := o.val + otherVal := otherNtv.val + if reflect.TypeOf(val).Kind() != reflect.TypeOf(otherVal).Kind() { + val = o.refValue.Interface() + otherVal = otherNtv.refValue.Interface() + } + return Bool(reflect.DeepEqual(val, otherVal)) +} + +func (o *nativeObj) IsZeroValue() bool { + return o.refValue.IsZero() +} + +func (o *nativeObj) IsSet(field ref.Val) ref.Val { + refField, refErr := o.getReflectedField(field) + if refErr != nil { + return refErr + } + return Bool(!refField.IsZero()) +} + +func (o *nativeObj) Get(field ref.Val) ref.Val { + refField, refErr := o.getReflectedField(field) + if refErr != nil { + return refErr + } + return adaptFieldValue(o, refField) +} + +func (o *nativeObj) getReflectedField(field ref.Val) (reflect.Value, ref.Val) { + fieldName, ok := field.(String) + if !ok { + return reflect.Value{}, MaybeNoSuchOverloadErr(field) + } + fieldNameStr := string(fieldName) + refField, isDefined := o.valType.hasField(fieldNameStr) + if !isDefined { + return reflect.Value{}, NewErr("no such field: %s", fieldName) + } + refVal := reflect.Indirect(o.refValue) + return safeGetFieldByIndex(refVal, refField.Index), nil +} + +func (o *nativeObj) Type() ref.Type { + return o.valType +} + +func (o *nativeObj) Value() any { + return o.val +} + +// AggregateSize implements the AggregateSizeVisitor interface method. +func (o *nativeObj) AggregateSize(sizer AggregateSizer) uint32 { + refVal := reflect.Indirect(o.refValue) + if !refVal.IsValid() { + return 0 + } + total := uint32(1) + for _, fieldType := range o.valType.fieldsByName { + fieldValue := safeGetFieldByIndex(refVal, fieldType.Index) + if !fieldValue.IsValid() || fieldValue.IsZero() { + continue + } + total = safeAddUint32(total, sizer.AggregateSize(fieldValue)) + } + return total +} + +func newNativeTypes(rawType reflect.Type, fieldNameHandler NativeTypesFieldNameHandler) ([]*NativeType, error) { + nt, err := newNativeType(rawType, fieldNameHandler) + if err != nil { + return nil, err + } + result := []*NativeType{nt} + + alreadySeen := make(map[string]struct{}) + var iterateStructMembers func(reflect.Type) + iterateStructMembers = func(t reflect.Type) { + if t.Implements(reflect.TypeFor[ref.Val]()) { + return + } + if k := t.Kind(); k == reflect.Pointer || k == reflect.Slice || k == reflect.Array || k == reflect.Map { + iterateStructMembers(t.Elem()) + return + } + if t.Kind() != reflect.Struct { + return + } + if _, seen := alreadySeen[t.String()]; seen { + return + } + alreadySeen[t.String()] = struct{}{} + nt, ntErr := newNativeType(t, fieldNameHandler) + if ntErr != nil { + err = ntErr + return + } + result = append(result, nt) + + for _, field := range reflect.VisibleFields(t) { + if !field.IsExported() || !isSupportedType(field.Type) { + continue + } + iterateStructMembers(field.Type) + } + } + iterateStructMembers(rawType) + + return result, err +} + +func toFieldName(f reflect.StructField, fieldNameHandler NativeTypesFieldNameHandler) string { + if fieldNameHandler == nil { + return f.Name + } + return fieldNameHandler(f) +} + +func newNativeType(rawType reflect.Type, fieldNameHandler NativeTypesFieldNameHandler) (*NativeType, error) { + refType := rawType + if refType.Kind() == reflect.Pointer { + refType = refType.Elem() + } + if !isValidObjectType(refType) { + return nil, fmt.Errorf("unsupported reflect.Type %v, must be reflect.Struct", rawType) + } + + fieldsByName := make(map[string]reflect.StructField) + for _, field := range reflect.VisibleFields(refType) { + if !field.IsExported() || !isSupportedType(field.Type) { + continue + } + fieldName := toFieldName(field, fieldNameHandler) + if isSkippedFieldName(fieldName) { + continue + } + if _, found := fieldsByName[fieldName]; found { + return nil, fmt.Errorf("invalid field name `%s` in struct `%s`: %w", fieldName, refType.Name(), errDuplicatedFieldName) + } + fieldsByName[fieldName] = field + } + + return &NativeType{ + typeName: fmt.Sprintf("%s.%s", simplePkgAlias(refType.PkgPath()), refType.Name()), + refType: refType, + fieldsByName: fieldsByName, + }, nil +} + +func adaptFieldValue(adapter Adapter, refField reflect.Value) ref.Val { + return adapter.NativeToValue(getFieldValue(refField)) +} + +func safeSetFieldByIndex(v reflect.Value, index []int) reflect.Value { + for _, i := range index { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + if v.Kind() != reflect.Struct || i >= v.NumField() { + return reflect.Value{} + } + v = v.Field(i) + } + return v +} + +func safeGetFieldByIndex(v reflect.Value, index []int) reflect.Value { + for _, i := range index { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + v = reflect.New(v.Type().Elem()).Elem() + } else { + v = v.Elem() + } + } + if v.Kind() != reflect.Struct || i >= v.NumField() { + return reflect.Value{} + } + v = v.Field(i) + } + return v +} + +func getFieldValue(refField reflect.Value) any { + if !refField.IsValid() { + return nil + } + if refField.IsZero() { + switch refField.Kind() { + case reflect.Struct: + if refField.Type() == timestampType { + return time.Unix(0, 0) + } + case reflect.Pointer: + return reflect.New(refField.Type().Elem()).Interface() + } + } + return refField.Interface() +} + +func simplePkgAlias(pkgPath string) string { + paths := strings.Split(pkgPath, "/") + if len(paths) == 0 { + return "" + } + return paths[len(paths)-1] +} + +func isValidObjectType(refType reflect.Type) bool { + return refType.Kind() == reflect.Struct +} + +func isSupportedType(refType reflect.Type) bool { + switch refType.Kind() { + case reflect.Chan, reflect.Complex64, reflect.Complex128, reflect.Func, reflect.UnsafePointer, reflect.Uintptr: + return false + case reflect.Array, reflect.Slice: + return isSupportedType(refType.Elem()) + case reflect.Map: + return isSupportedType(refType.Key()) && isSupportedType(refType.Elem()) + } + return true +} + +func convertToCelType(refType reflect.Type) (*Type, bool) { + switch refType.Kind() { + case reflect.Bool: + return BoolType, true + case reflect.Float32, reflect.Float64: + return DoubleType, true + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if refType == durationType { + return DurationType, true + } + return IntType, true + case reflect.String: + return StringType, true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return UintType, true + case reflect.Array, reflect.Slice: + refElem := refType.Elem() + if refElem == reflect.TypeOf(byte(0)) { + return BytesType, true + } + elemType, ok := convertToCelType(refElem) + if !ok { + return nil, false + } + return NewListType(elemType), true + case reflect.Map: + keyType, ok := convertToCelType(refType.Key()) + if !ok { + return nil, false + } + elemType, ok := convertToCelType(refType.Elem()) + if !ok { + return nil, false + } + return NewMapType(keyType, elemType), true + case reflect.Struct: + if refType == timestampType { + return TimestampType, true + } + if refType.Implements(refValType) { + emptyCelVal := reflect.New(refType).Elem().Interface().(ref.Val) + return emptyCelVal.Type().(*Type), true + } + return NewObjectType( + fmt.Sprintf("%s.%s", simplePkgAlias(refType.PkgPath()), refType.Name()), + ), true + case reflect.Pointer: + if refType.Implements(refValType) { + emptyCelVal := reflect.New(refType.Elem()).Interface().(ref.Val) + return emptyCelVal.Type().(*Type), true + } + if refType.Implements(pbMsgInterfaceType) { + pbMsg := reflect.New(refType.Elem()).Interface().(protoreflect.ProtoMessage) + return NewObjectType(string(pbMsg.ProtoReflect().Descriptor().FullName())), true + } + return convertToCelType(refType.Elem()) + } + return nil, false +} diff --git a/common/types/native_test.go b/common/types/native_test.go new file mode 100644 index 000000000..a0b76e38a --- /dev/null +++ b/common/types/native_test.go @@ -0,0 +1,1704 @@ +// Copyright 2022 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 +// +// 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 types_test + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strings" + "testing" + "time" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/authzed/cel-go/cel" + "github.com/authzed/cel-go/common/types" + "github.com/authzed/cel-go/common/types/pb" + "github.com/authzed/cel-go/common/types/ref" + "github.com/authzed/cel-go/common/types/traits" + "github.com/authzed/cel-go/ext" + "github.com/authzed/cel-go/test" + + structpb "google.golang.org/protobuf/types/known/structpb" + + proto3pb "github.com/authzed/cel-go/test/proto3pb" +) + +func TestNativeTypes(t *testing.T) { + var nativeTests = []struct { + expr string + out any + in any + envOpts []any + }{ + { + expr: `types_test.TestAllTypes{ + NestedVal: types_test.TestNestedType{NestedMapVal: {1: false}}, + BoolVal: true, + BytesVal: b'hello', + DurationVal: duration('5s'), + DoubleVal: 1.5, + FloatVal: 2.5, + Int32Val: 10, + Int64Val: 20, + StringVal: 'hello world', + TimestampVal: timestamp('2011-08-06T01:23:45Z'), + Uint32Val: 100u, + Uint64Val: 200u, + ListVal: [ + types_test.TestNestedType{ + NestedListVal:['goodbye', 'cruel', 'world'], + NestedMapVal: {42: true}, + custom_name: 'name', + }, + ], + ArrayVal: [ + types_test.TestNestedType{ + NestedListVal:['goodbye', 'cruel', 'world'], + NestedMapVal: {42: true}, + custom_name: 'name', + }, + ], + MapVal: {'map-key': types_test.TestAllTypes{BoolVal: true}}, + CustomSliceVal: [types_test.TestNestedSliceType{Value: 'none'}], + CustomMapVal: {'even': types_test.TestMapVal{Value: 'more'}}, + custom_name: 'name', + }`, + out: &TestAllTypes{ + NestedVal: &TestNestedType{NestedMapVal: map[int64]bool{1: false}}, + BoolVal: true, + BytesVal: []byte("hello"), + DurationVal: time.Second * 5, + DoubleVal: 1.5, + FloatVal: 2.5, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + TimestampVal: mustParseTime(t, "2011-08-06T01:23:45Z"), + Uint32Val: uint32(100), + Uint64Val: uint64(200), + ListVal: []*TestNestedType{ + { + NestedListVal: []string{"goodbye", "cruel", "world"}, + NestedMapVal: map[int64]bool{42: true}, + NestedCustomName: "name", + }, + }, + ArrayVal: [1]*TestNestedType{{ + NestedListVal: []string{"goodbye", "cruel", "world"}, + NestedMapVal: map[int64]bool{42: true}, + NestedCustomName: "name", + }}, + MapVal: map[string]TestAllTypes{"map-key": {BoolVal: true}}, + CustomSliceVal: []TestNestedSliceType{{Value: "none"}}, + CustomMapVal: map[string]TestMapVal{"even": {Value: "more"}}, + CustomName: "name", + }, + envOpts: []any{types.ParseStructTags(true)}, + }, + + { + expr: `types_test.TestAllTypes{ + nestedVal: types_test.TestNestedType{NestedMapVal: {1: false}}, + boolVal: true, + BytesVal: b'hello', + DurationVal: duration('5s'), + DoubleVal: 1.5, + FloatVal: 2.5, + Int32Val: 10, + Int64Val: 20, + StringVal: 'hello world', + TimestampVal: timestamp('2011-08-06T01:23:45Z'), + Uint32Val: 100u, + Uint64Val: 200u, + ListVal: [ + types_test.TestNestedType{ + NestedListVal:['goodbye', 'cruel', 'world'], + NestedMapVal: {42: true}, + custom_name: 'name', + }, + ], + ArrayVal: [ + types_test.TestNestedType{ + NestedListVal:['goodbye', 'cruel', 'world'], + NestedMapVal: {42: true}, + custom_name: 'name', + }, + ], + MapVal: {'map-key': types_test.TestAllTypes{boolVal: true}}, + CustomSliceVal: [types_test.TestNestedSliceType{Value: 'none'}], + CustomMapVal: {'even': types_test.TestMapVal{Value: 'more'}}, + CustomName: 'name', + }`, + out: &TestAllTypes{ + NestedVal: &TestNestedType{NestedMapVal: map[int64]bool{1: false}}, + BoolVal: true, + BytesVal: []byte("hello"), + DurationVal: time.Second * 5, + DoubleVal: 1.5, + FloatVal: 2.5, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + TimestampVal: mustParseTime(t, "2011-08-06T01:23:45Z"), + Uint32Val: uint32(100), + Uint64Val: uint64(200), + ListVal: []*TestNestedType{ + { + NestedListVal: []string{"goodbye", "cruel", "world"}, + NestedMapVal: map[int64]bool{42: true}, + NestedCustomName: "name", + }, + }, + ArrayVal: [1]*TestNestedType{{ + NestedListVal: []string{"goodbye", "cruel", "world"}, + NestedMapVal: map[int64]bool{42: true}, + NestedCustomName: "name", + }}, + MapVal: map[string]TestAllTypes{"map-key": {BoolVal: true}}, + CustomSliceVal: []TestNestedSliceType{{Value: "none"}}, + CustomMapVal: map[string]TestMapVal{"even": {Value: "more"}}, + CustomName: "name", + }, + envOpts: []any{types.ParseStructTag("json")}, + }, + { + expr: `types_test.TestAllTypes{ + NestedVal: types_test.TestNestedType{NestedMapVal: {1: false}}, + BoolVal: true, + BytesVal: b'hello', + DurationVal: duration('5s'), + DoubleVal: 1.5, + FloatVal: 2.5, + Int32Val: 10, + Int64Val: 20, + StringVal: 'hello world', + TimestampVal: timestamp('2011-08-06T01:23:45Z'), + Uint32Val: 100u, + Uint64Val: 200u, + ListVal: [ + types_test.TestNestedType{ + NestedListVal:['goodbye', 'cruel', 'world'], + NestedMapVal: {42: true}, + NestedCustomName: 'name', + }, + ], + ArrayVal: [ + types_test.TestNestedType{ + NestedListVal:['goodbye', 'cruel', 'world'], + NestedMapVal: {42: true}, + NestedCustomName: 'name', + }, + ], + MapVal: {'map-key': types_test.TestAllTypes{BoolVal: true}}, + CustomSliceVal: [types_test.TestNestedSliceType{Value: 'none'}], + CustomMapVal: {'even': types_test.TestMapVal{Value: 'more'}}, + CustomName: 'name', + }`, + out: &TestAllTypes{ + NestedVal: &TestNestedType{NestedMapVal: map[int64]bool{1: false}}, + BoolVal: true, + BytesVal: []byte("hello"), + DurationVal: time.Second * 5, + DoubleVal: 1.5, + FloatVal: 2.5, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + TimestampVal: mustParseTime(t, "2011-08-06T01:23:45Z"), + Uint32Val: uint32(100), + Uint64Val: uint64(200), + ListVal: []*TestNestedType{ + { + NestedListVal: []string{"goodbye", "cruel", "world"}, + NestedMapVal: map[int64]bool{42: true}, + NestedCustomName: "name", + }, + }, + ArrayVal: [1]*TestNestedType{{ + NestedListVal: []string{"goodbye", "cruel", "world"}, + NestedMapVal: map[int64]bool{42: true}, + NestedCustomName: "name", + }}, + MapVal: map[string]TestAllTypes{"map-key": {BoolVal: true}}, + CustomSliceVal: []TestNestedSliceType{{Value: "none"}}, + CustomMapVal: map[string]TestMapVal{"even": {Value: "more"}}, + CustomName: "name", + }, + }, + { + expr: `types_test.TestAllTypes{ + PbVal: test.TestAllTypes{single_int32: 123} + }.PbVal`, + out: &proto3pb.TestAllTypes{SingleInt32: 123}, + }, + { + expr: `types_test.TestAllTypes{PbVal: test.TestAllTypes{}} == + types_test.TestAllTypes{PbVal: test.TestAllTypes{single_bool: false}}`, + }, + {expr: `types_test.TestNestedType{} == TestNestedType{}`}, + {expr: `types_test.TestAllTypes{}.BoolVal != true`}, + {expr: `!has(types_test.TestAllTypes{}.BoolVal) && !has(types_test.TestAllTypes{}.NestedVal)`}, + {expr: `type(types_test.TestAllTypes) == type`}, + {expr: `type(types_test.TestAllTypes{}) == types_test.TestAllTypes`}, + {expr: `type(types_test.TestAllTypes{}) == types_test.TestAllTypes`}, + {expr: `types_test.TestAllTypes != test.TestAllTypes`}, + {expr: `types_test.TestAllTypes{BoolVal: true} != dyn(test.TestAllTypes{single_bool: true})`}, + {expr: `types_test.TestAllTypes{}.NestedVal == types_test.TestNestedType{}`}, + {expr: `types_test.TestNestedType{} == types_test.TestAllTypes{}.NestedStructVal`}, + {expr: `types_test.TestAllTypes{}.NestedStructVal == types_test.TestNestedType{}`}, + {expr: `types_test.TestAllTypes{}.ListVal.size() == 0`}, + {expr: `types_test.TestAllTypes{}.MapVal.size() == 0`}, + {expr: `types_test.TestAllTypes{}.TimestampVal == timestamp(0)`}, + {expr: `test.TestAllTypes{}.single_timestamp == timestamp(0)`}, + {expr: `[TestAllTypes{BoolVal: true}, TestAllTypes{BoolVal: false}].exists(t, t.BoolVal == true)`}, + {expr: `[TestAllTypes{CustomName: 'Alice'}, TestAllTypes{CustomName: 'Bob'}].exists(t, t.CustomName == 'Alice')`}, + {expr: `[TestAllTypes{custom_name: 'Alice'}, TestAllTypes{custom_name: 'Bob'}].exists(t, t.custom_name == 'Alice')`, envOpts: []any{types.ParseStructTags(true)}}, + {expr: `TestAllTypes{BytesArrayVal: b'1234'}.BytesArrayVal != b'123'`}, + {expr: `TestAllTypes{BytesArrayVal: b'1234'}.BytesArrayVal == b'1234'`}, + { + expr: `tests.all(t, t.Int32Val > 17)`, + in: map[string]any{ + "tests": []*TestAllTypes{{Int32Val: 18}, {Int32Val: 19}, {Int32Val: 20}}, + }, + }, + } + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + env := testNativeEnv(t, tc.envOpts...) + var asts []*cel.Ast + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, pAst) + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, cAst) + for _, ast := range asts { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + in := tc.in + if in == nil { + in = cel.NoVars() + } + out, _, err := prg.Eval(in) + if err != nil { + t.Fatal(err) + } + want := tc.out + if want == nil { + want = true + } + wantPB, isPB := want.(proto.Message) + if isPB && !pb.Equal(wantPB, out.Value().(proto.Message)) { + t.Errorf("got %v, wanted %v for expr: %s", out.Value(), want, tc.expr) + } + if !isPB && !reflect.DeepEqual(out.Value(), want) { + t.Errorf("got %v, wanted %v for expr: %s", out.Value(), want, tc.expr) + } + } + }) + } +} + +func TestNativeFindStructFieldNames(t *testing.T) { + env := testNativeEnv(t, types.ParseStructTags(true)) + provider := env.CELTypeProvider() + tests := []struct { + typeName string + fields []string + }{ + { + typeName: "types_test.TestNestedType", + fields: []string{"NestedListVal", "NestedMapVal", "custom_name"}, + }, + { + typeName: "google.expr.proto3.test.TestAllTypes.NestedMessage", + fields: []string{"bb"}, + }, + { + typeName: "invalid.TypeName", + fields: []string{}, + }, + } + + for _, tst := range tests { + tc := tst + t.Run(fmt.Sprintf("%s", tc.typeName), func(t *testing.T) { + fields, _ := provider.FindStructFieldNames(tc.typeName) + sort.Strings(fields) + sort.Strings(tc.fields) + if !reflect.DeepEqual(fields, tc.fields) { + t.Errorf("got %v, wanted %v", fields, tc.fields) + } + }) + } +} + +func TestNativeTypesStaticErrors(t *testing.T) { + var nativeTests = []struct { + expr string + err string + }{ + { + expr: `TestAllTypos{}`, + err: `ERROR: :1:13: undeclared reference to 'TestAllTypos' (in container 'types_test') + | TestAllTypos{} + | ............^`, + }, + { + expr: `types_test.TestAllTypes{bool_val: false}`, + err: `ERROR: :1:33: undefined field 'bool_val' + | types_test.TestAllTypes{bool_val: false} + | ................................^`, + }, + { + expr: `types_test.TestAllTypes{UnsupportedVal: null}`, + err: `ERROR: :1:39: undefined field 'UnsupportedVal' + | types_test.TestAllTypes{UnsupportedVal: null} + | ......................................^`, + }, + { + expr: `types_test.TestAllTypes{UnsupportedListVal: null}`, + err: `ERROR: :1:43: undefined field 'UnsupportedListVal' + | types_test.TestAllTypes{UnsupportedListVal: null} + | ..........................................^`, + }, + { + expr: `types_test.TestAllTypes{UnsupportedMapVal: null}`, + err: `ERROR: :1:42: undefined field 'UnsupportedMapVal' + | types_test.TestAllTypes{UnsupportedMapVal: null} + | .........................................^`, + }, + } + env := testNativeEnv(t) + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + _, iss := env.Compile(tc.expr) + if iss.Err() == nil { + t.Fatalf("env.Compile(%v) succeeded, wanted error", tc.expr) + } + if !test.Compare(iss.Err().Error(), tc.err) { + t.Errorf("env.Compile(%v) got %v, wanted error %s", tc.expr, iss.Err(), tc.err) + } + }) + } +} + +func TestNativeTypesJsonSerialization(t *testing.T) { + tests := []struct { + expr string + out string + additionalEnvOptions []any + }{ + { + expr: `[b'string']`, + out: `["c3RyaW5n"]`, + }, + { + expr: `TestAllTypes{ + BoolVal: true, + DurationVal: duration('5s'), + DoubleVal: 1.5, + FloatVal: 2.0, + Int32Val: 23, + Int64Val: 64, + MapVal: { + 'map-key': types_test.TestAllTypes{ + BoolVal: true + } + }, + NestedVal: TestNestedType{ + NestedListVal: ["first", "second"], + }, + StringVal: "string", + CustomName: "name", + }`, + out: `{ + "BoolVal": true, + "CustomName": "name", + "DoubleVal": 1.5, + "DurationVal": "5s", + "FloatVal": 2, + "Int32Val": 23, + "Int64Val": 64, + "MapVal": { + "map-key": { + "BoolVal": true + } + }, + "NestedVal": { + "NestedListVal": [ + "first", + "second" + ] + }, + "StringVal": "string" + }`, + }, + { + expr: `TestAllTypes{ + BoolVal: true, + DurationVal: duration('5s'), + DoubleVal: 1.5, + FloatVal: 2.0, + Int32Val: 23, + Int64Val: 64, + MapVal: { + 'map-key': types_test.TestAllTypes{ + BoolVal: true + } + }, + NestedVal: TestNestedType{ + NestedListVal: ["first", "second"], + }, + StringVal: "string", + custom_name: "name", + }`, + out: `{ + "BoolVal": true, + "DoubleVal": 1.5, + "DurationVal": "5s", + "FloatVal": 2, + "Int32Val": 23, + "Int64Val": 64, + "MapVal": { + "map-key": { + "BoolVal": true + } + }, + "NestedVal": { + "NestedListVal": [ + "first", + "second" + ] + }, + "StringVal": "string", + "custom_name": "name" + }`, + additionalEnvOptions: []any{types.ParseStructTags(true)}, + }, + } + for i, tst := range tests { + tc := tst + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + env := testNativeEnv(t, tst.additionalEnvOptions...) + ast, iss := env.Compile(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Compile(%v) failed: %v", tc.expr, iss.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("env.Program() failed: %v", err) + } + out, _, err := prg.Eval(cel.NoVars()) + if err != nil { + t.Fatalf("prg.Eval() failed: %v", err) + } + conv, err := out.ConvertToNative(reflect.TypeOf(&structpb.Value{})) + if err != nil { + t.Fatalf("out.ConvertToNative(Value) failed: %v", err) + } + json := protojson.Format(conv.(proto.Message)) + if !test.Compare(json, tc.out) { + t.Errorf("expr %v converted to %v, wanted %v", tc.expr, json, tc.out) + } + }) + } +} + +func TestNativeTypesRuntimeErrors(t *testing.T) { + var nativeTests = []struct { + expr string + err string + }{ + { + expr: `TestAllTypos{}`, + err: `unknown type: TestAllTypos`, + }, + { + expr: `types_test.TestAllTypes{bool_val: false}`, + err: `no such field: bool_val`, + }, + { + expr: `types_test.TestAllTypes{UnsupportedVal: null}`, + err: `no such field: UnsupportedVal`, + }, + { + expr: `types_test.TestAllTypes{UnsupportedListVal: null}`, + err: `no such field: UnsupportedListVal`, + }, + { + expr: `types_test.TestAllTypes{UnsupportedMapVal: null}`, + err: `no such field: UnsupportedMapVal`, + }, + { + expr: `types_test.TestAllTypes{privateVal: null}`, + err: `no such field: privateVal`, + }, + { + expr: `types_test.TestAllTypes{}.UnsupportedMapVal`, + err: `no such field: UnsupportedMapVal`, + }, + { + expr: `types_test.TestAllTypes{}.privateVal`, + err: `no such field: privateVal`, + }, + { + expr: `types_test.TestAllTypes{BoolVal: 'false'}`, + err: `unsupported native conversion from string to 'bool'`, + }, + { + expr: `has(types_test.TestAllTypes{}.BadFieldName)`, + err: `no such field: BadFieldName`, + }, + { + expr: `types_test.TestAllTypes{}[42]`, + err: `no such overload`, + }, + { + expr: `types_test.TestAllTypes{Int32Val: 9223372036854775807}`, + err: `integer overflow`, + }, + { + expr: `types_test.TestAllTypes{Uint32Val: 9223372036854775807u}`, + err: `unsigned integer overflow`, + }, + } + env := testNativeEnv(t) + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + ast, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + prg, err := env.Program(ast) + if err != nil { + if !strings.Contains(err.Error(), tc.err) { + t.Fatal(err) + } + return + } + out, _, err := prg.Eval(cel.NoVars()) + if err == nil || !strings.Contains(err.Error(), tc.err) { + var got any = err + if err == nil { + got = out + } + t.Fatalf("prg.Eval() got %v, wanted error %v", got, tc.err) + } + }) + } +} + +func TestNativeTypesErrors(t *testing.T) { + envTests := []struct { + nativeType any + err string + }{ + { + nativeType: reflect.TypeOf(1), + err: "unsupported reflect.Type", + }, + { + nativeType: reflect.ValueOf(1), + err: "unsupported reflect.Type", + }, + { + nativeType: 1, + err: "must be reflect.Type or reflect.Value", + }, + } + for i, tst := range envTests { + tc := tst + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + _, err := cel.NewEnv(ext.NativeTypes(tc.nativeType)) + if err == nil || !strings.Contains(err.Error(), tc.err) { + t.Errorf("cel.NewEnv(NativeTypes(%v)) got error %v, wanted %v", tc.nativeType, err, tc.err) + } + }) + } +} + +func TestNativeTypesConvertToNative(t *testing.T) { + env := testNativeEnv(t, ext.NativeTypes(reflect.TypeOf(TestNestedType{}))) + adapter := env.CELTypeAdapter() + conversions := []struct { + in any + inType *cel.Type + out any + err string + }{ + { + in: &TestAllTypes{BoolVal: true}, + inType: cel.ObjectType("types_test.TestAllTypes"), + out: &TestAllTypes{BoolVal: true}, + }, + { + in: TestAllTypes{BoolVal: true}, + inType: cel.ObjectType("types_test.TestAllTypes"), + out: &TestAllTypes{BoolVal: true}, + }, + { + in: &TestAllTypes{BoolVal: true}, + inType: cel.ObjectType("types_test.TestAllTypes"), + out: TestAllTypes{BoolVal: true}, + }, + { + in: nil, + inType: cel.NullType, + out: types.NullValue, + }, + { + in: &TestAllTypes{BoolVal: true}, + inType: cel.ObjectType("types_test.TestAllTypes"), + out: &proto3pb.TestAllTypes{}, + err: "type conversion error", + }, + { + in: [3]int32{1, 2, 3}, + inType: cel.ListType(cel.IntType), + out: []int32{1, 2, 3}, + }, + { + in: &[3]byte{1, 2, 3}, + inType: cel.BytesType, + out: []byte{1, 2, 3}, + }, + { + in: [3]byte{1, 2, 3}, + inType: cel.BytesType, + out: []byte{1, 2, 3}, + }, + } + for _, c := range conversions { + inVal := adapter.NativeToValue(c.in) + if types.IsError(inVal) { + t.Fatalf("adapter.NativeToValue(%v) failed: %v", c.in, inVal) + } + if inVal.Type().TypeName() != c.inType.TypeName() { + t.Fatalf("adapter.NativeToValue() got type %v, wanted type %v", inVal.Type(), c.inType) + } + out, err := inVal.ConvertToNative(reflect.TypeOf(c.out)) + if err != nil { + if c.err != "" { + if !strings.Contains(err.Error(), c.err) { + t.Fatalf("%v.ConvertToNative(%T) got %v, wanted error %v", c.in, c.out, err, c.err) + } + return + } + t.Fatalf("%v.ConvertToNative(%T) failed: %v", c.in, c.out, err) + } + if !reflect.DeepEqual(out, c.out) { + t.Errorf("%v.ConvertToNative(%T) got %v, wanted %v", c.in, c.out, out, c.out) + } + } +} + +func TestConvertToTypeErrors(t *testing.T) { + env := testNativeEnv(t, ext.NativeTypes(reflect.TypeOf(TestNestedType{}))) + adapter := env.CELTypeAdapter() + conversions := []struct { + in any + out any + err string + }{ + { + in: &TestAllTypes{BoolVal: true}, + out: &TestAllTypes{BoolVal: true}, + }, + { + in: TestAllTypes{BoolVal: true}, + out: &TestAllTypes{BoolVal: true}, + }, + { + in: &TestAllTypes{BoolVal: true}, + out: TestAllTypes{BoolVal: true}, + }, + { + in: &TestAllTypes{BoolVal: true}, + out: &proto3pb.TestAllTypes{}, + err: "type conversion error", + }, + } + for _, c := range conversions { + inVal := adapter.NativeToValue(c.in) + outVal := adapter.NativeToValue(c.out) + if types.IsError(inVal) { + t.Fatalf("adapter.NativeToValue(%v) failed: %v", c.in, inVal) + } + if types.IsError(outVal) { + t.Fatalf("adapter.NativeToValue(%v) failed: %v", c.out, outVal) + } + conv := inVal.ConvertToType(outVal.Type()) + if c.err != "" { + if !types.IsError(conv) { + t.Fatalf("%v.ConvertToType(%v) got %v, wanted error %v", c.in, outVal.Type(), conv, c.err) + } + convErr := conv.(*types.Err) + if !strings.Contains(convErr.Error(), c.err) { + t.Fatalf("%v.ConvertToType(%v) got %v, wanted error %v", c.in, outVal.Type(), conv, c.err) + } + return + } + if conv != inVal { + t.Errorf("%v.ConvertToType(%v) got %v, wanted %v", c.in, outVal.Type(), conv, c.err) + } + conv = inVal.ConvertToType(types.TypeType) + if conv.Type() != types.TypeType || conv.(ref.Type) != inVal.Type() { + t.Errorf("%v.ConvertToType(Type) got %v, wanted %v", inVal, conv, inVal.Type()) + } + } +} + +func TestNativeTypesWithOptional(t *testing.T) { + var nativeTests = []struct { + expr string + }{ + {expr: `!optional.ofNonZeroValue(types_test.TestAllTypes{}).hasValue()`}, + {expr: `!types_test.TestAllTypes{}.?BoolVal.orValue(false)`}, + {expr: `!types_test.TestAllTypes{}.?BoolVal.hasValue()`}, + {expr: `!types_test.TestAllTypes{BoolVal: false}.?BoolVal.hasValue()`}, + {expr: `types_test.TestAllTypes{BoolVal: true}.?BoolVal.hasValue()`}, + {expr: `types_test.TestAllTypes{}.NestedVal.?NestedMapVal.orValue({}).size() == 0`}, + } + env := testNativeEnv(t, cel.OptionalTypes()) + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + var asts []*cel.Ast + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, pAst) + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, cAst) + for _, ast := range asts { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(cel.NoVars()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out.Value(), true) { + t.Errorf("got %v, wanted true for expr: %s", out.Value(), tc.expr) + } + } + }) + } +} + +func TestNativeTypesWithCELTypedFields(t *testing.T) { + var nativeTests = []struct { + expr string + }{ + { + expr: `types_test.TestRefValFieldType{optional_name: optional.of('my name')}.optional_name.orValue('') == 'my name'`, + }, + { + expr: `types_test.TestRefValFieldType{IntVal: 2}.IntVal >= 1`, + }, + { + expr: `types_test.TestRefValFieldType{time: timestamp('2001-01-01T00:00:00Z')}.time > timestamp('1970-01-01T00:00:00Z')`, + }, + } + env := testNativeEnv(t, cel.OptionalTypes(), types.ParseStructTag("cel")) + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + var asts []*cel.Ast + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, pAst) + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, cAst) + for _, ast := range asts { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(cel.NoVars()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out.Value(), true) { + t.Errorf("got %v, wanted true for expr: %s", out.Value(), tc.expr) + } + } + }) + } +} + +func TestNativeTypeConvertToType(t *testing.T) { + var nativeTests = []struct { + tag string + }{ + {tag: "cel"}, + {tag: "json"}, + } + + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + handler := func(f reflect.StructField) string { + tag, found := f.Tag.Lookup(tc.tag) + if found { + splits := strings.Split(tag, ",") + if len(splits) > 0 { + return splits[0] + } + } + return f.Name + } + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes](), types.ParseStructField(handler)) + if err != nil { + t.Fatalf("NewNativeType() failed: %v", err) + } + if nt.ConvertToType(types.TypeType) != types.TypeType { + t.Error("ConvertToType(Type) failed") + } + if !types.IsError(nt.ConvertToType(types.StringType)) { + t.Errorf("ConvertToType(String) got %v, wanted error", nt.ConvertToType(types.StringType)) + } + }) + } +} + +func TestNativeTypeConvertToNative(t *testing.T) { + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes]()) + if err != nil { + t.Fatalf("NewNativeType() failed: %v", err) + } + out, err := nt.ConvertToNative(reflect.TypeOf(1)) + if err == nil { + t.Errorf("nt.ConvertToNative(1) produced %v, wanted error", out) + } +} + +func TestNativeTypeHasTrait(t *testing.T) { + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes]()) + if err != nil { + t.Fatalf("NewNativeType() failed: %v", err) + } + if !nt.HasTrait(traits.IndexerType) || !nt.HasTrait(traits.FieldTesterType) { + t.Error("nt.HasTrait() failed indicate support for presence test and field access.") + } +} + +func TestNativeTypeValue(t *testing.T) { + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes]()) + if err != nil { + t.Fatalf("NewNativeType() failed: %v", err) + } + if nt.Value() != nt.String() { + t.Errorf("nt.Value() got %v, wanted %v", nt.Value(), nt.String()) + } +} + +func TestNativeStructWithMultipleSameFieldNames(t *testing.T) { + tagHandler := func(f reflect.StructField) string { + tag, found := f.Tag.Lookup("cel") + if found { + splits := strings.Split(tag, ",") + if len(splits) > 0 { + return splits[0] + } + } + return f.Name + } + _, err := types.NewNativeType( + reflect.TypeFor[TestStructWithMultipleSameNames](), + types.ParseStructField(tagHandler), + ) + if err == nil { + t.Fatal("NewNativeType() did not fail as expected") + } + if !strings.Contains(err.Error(), "field name already exists") { + t.Fatalf("NewNativeType() expected duplicated field name error, but got: %v", err) + } +} + +func TestNativeStructEmbedded(t *testing.T) { + var nativeTests = []struct { + expr string + in any + out any + }{ + { + expr: `test.embedded.custom_name == "name"`, + in: map[string]any{ + "test": &TestEmbeddedTypes{ + TestNestedType: TestNestedType{NestedCustomName: "name"}, + Skipped: "should-be-hidden", + }, + }, + out: true, + }, + { + expr: `dyn(test.embedded)["-"] == "error"`, + in: map[string]any{ + "test": &TestEmbeddedTypes{ + TestNestedType: TestNestedType{NestedCustomName: "name"}, + Skipped: "should-be-hidden", + }, + }, + out: errors.New("no such field: -"), + }, + { + expr: `test.embedded == types_test.TestNestedType{custom_name: "name"}`, + in: map[string]any{ + "test": &TestEmbeddedTypes{ + TestNestedType: TestNestedType{NestedCustomName: "name"}, + Skipped: "should-be-hidden", + }, + }, + out: true, + }, + { + expr: `test.Name == "name"`, + in: map[string]any{ + "test": &TestEmbeddedTypes{ + Custom: Custom{Name: "name"}, + }, + }, + out: true, + }, + } + + envOpts := []cel.EnvOption{ + ext.NativeTypes( + reflect.TypeFor[*TestEmbeddedTypes](), + reflect.TypeFor[*TestNestedType](), + types.ParseStructTag("json"), + ), + cel.Variable("test", cel.ObjectType("types_test.TestEmbeddedTypes")), + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv(NativeTypes()) failed: %v", err) + } + + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + var asts []*cel.Ast + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, pAst) + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, cAst) + for _, ast := range asts { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(tc.in) + if err != nil { + if !errors.Is(err, tc.out.(error)) { + t.Fatalf("got %v, wanted %v for expr: %s", err, tc.out, tc.expr) + } + continue + } + if !reflect.DeepEqual(out.Value(), tc.out) { + t.Errorf("got %v, wanted %v for expr: %s", out.Value(), tc.out, tc.expr) + } + } + }) + } +} + +func TestNativeStructEmbeddedPointer(t *testing.T) { + nativeTests := []struct { + expr string + in map[string]any + out any + }{ + { + expr: `!has(test.custom_name) && test.custom_name == ""`, + in: map[string]any{ + "test": &TestEmbeddedPointerTypes{ + TestNestedType: nil, + }, + }, + out: true, + }, + { + expr: `has(test.custom_name) && test.custom_name == "name"`, + in: map[string]any{ + "test": &TestEmbeddedPointerTypes{ + TestNestedType: &TestNestedType{NestedCustomName: "name"}, + }, + }, + out: true, + }, + { + expr: `types_test.TestEmbeddedPointerTypes{custom_name: "name"}.custom_name == "name"`, + in: nil, + out: true, + }, + } + + envOpts := []cel.EnvOption{ + ext.NativeTypes( + reflect.TypeFor[*TestEmbeddedPointerTypes](), + reflect.TypeFor[*TestNestedType](), + types.ParseStructTag("json"), + ), + cel.Variable("test", cel.ObjectType("types_test.TestEmbeddedPointerTypes")), + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv(NativeTypes()) failed: %v", err) + } + + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + for _, ast := range []*cel.Ast{pAst, cAst} { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(tc.in) + if err != nil { + t.Fatalf("prg.Eval() failed: %v", err) + } + if !reflect.DeepEqual(out.Value(), tc.out) { + t.Errorf("got %v, wanted %v for expr: %s", out.Value(), tc.out, tc.expr) + } + } + }) + } +} + +func TestNativeStructHiddenField(t *testing.T) { + envOpts := []cel.EnvOption{ + ext.NativeTypes( + reflect.TypeFor[*TestEmbeddedTypes](), + types.ParseStructTag("json"), + ), + cel.Variable("test", cel.ObjectType("types_test.TestEmbeddedTypes")), + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv(NativeTypes()) failed: %v", err) + } + + // 1. Static reference compilation failure case + // Attempting to compile `test.Password` should fail static analysis because the field is skipped/hidden. + _, iss := env.Compile("test.Password") + if iss.Err() == nil { + t.Error("env.Compile('test.Password') succeeded, expected a compilation/check error") + } + + // 2. Dynamic reference runtime evaluation failure case + // Using dyn(test).Password should compile successfully (since dyn disables static type checks), + // but it must fail at runtime during evaluation because the field is not exposed. + ast, iss := env.Compile("dyn(test).Password") + if iss.Err() != nil { + t.Fatalf("env.Compile('dyn(test).Password') failed: %v", iss.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("env.Program() failed: %v", err) + } + in := map[string]any{ + "test": &TestEmbeddedTypes{ + Skipped: "sensitive_password", + }, + } + out, _, err := prg.Eval(in) + if err == nil { + t.Errorf("prg.Eval() succeeded and returned %v, expected runtime error accessing hidden field", out) + } +} + +type TestNestedStruct struct { + ListVal []*TestNestedType +} + +func TestNativeNestedStruct(t *testing.T) { + var nativeTests = []struct { + expr string + in any + }{ + { + expr: `test.ListVal.exists(x, x.custom_name == "name")`, + in: map[string]any{ + "test": &TestNestedStruct{ListVal: []*TestNestedType{{NestedCustomName: "name"}}}, + }, + }, + } + + envOpts := []cel.EnvOption{ + ext.NativeTypes( + reflect.ValueOf(&TestNestedStruct{}), + types.ParseStructTag("json"), + ), + cel.Variable("test", cel.ObjectType("types_test.TestNestedStruct")), + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv(NativeTypes()) failed: %v", err) + } + + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + var asts []*cel.Ast + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, pAst) + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + asts = append(asts, cAst) + for _, ast := range asts { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(tc.in) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out.Value(), true) { + t.Errorf("got %v, wanted true for expr: %s", out.Value(), tc.expr) + } + } + }) + } +} + +func TestNativeTypesVersion(t *testing.T) { + _, err := cel.NewEnv(ext.NativeTypes(ext.NativeTypesVersion(0))) + if err != nil { + t.Fatalf("NewEnv(NativeTypes(NativeTypesVersion(0))) failed: %v", err) + } +} + +func TestTypeResolutionRace(t *testing.T) { + customType := reflect.TypeFor[*Custom]() + env, err := cel.NewEnv( + cel.Container("types_test"), + ext.NativeTypes( + types.ParseStructTag("cel"), + customType, + ), + ) + if err != nil { + t.Fatal("NewEnv:", err) + } + + tests := []struct { + name string + expr string + }{ + {name: "custom1", expr: `Custom{ name: "name1" }`}, + {name: "custom2", expr: `Custom{ name: "name2" }`}, + {name: "custom3", expr: `Custom{ name: "name3" }`}, + {name: "custom4", expr: `Custom{ name: "name4" }`}, + {name: "custom5", expr: `Custom{ name: "name5" }`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ast, iss := env.Compile(test.expr) + if err := iss.Err(); err != nil { + t.Fatal("Compile:", err) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("env.Program() failed: %s", err) + } + prg.Eval(cel.NoVars()) + }) + } +} + +func TestNativeToValueDelegatesUnregisteredStructs(t *testing.T) { + custom := &recordingAdapter{base: types.DefaultTypeAdapter} + env, err := cel.NewEnv( + cel.CustomTypeAdapter(custom), + ext.NativeTypes(reflect.TypeOf(registeredNativeStruct{})), + ) + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + adapter := env.CELTypeAdapter() + + // An unregistered struct must reach the composed base adapter. + got := adapter.NativeToValue(unregisteredNativeStruct{Name: "x"}) + if !custom.saw { + t.Error("base adapter was not consulted for an unregistered struct") + } + if got.Equal(types.String("from-base-adapter")) != types.True { + t.Errorf("NativeToValue(unregisteredNativeStruct) = %v, want the base adapter's value", got) + } + + // A registered native type must still be wrapped as a native object. + custom.saw = false + gotReg := adapter.NativeToValue(registeredNativeStruct{Name: "y"}) + if custom.saw { + t.Error("base adapter was consulted for a registered native type") + } + if tn := gotReg.Type().TypeName(); !strings.Contains(tn, "registeredNativeStruct") { + t.Errorf("NativeToValue(registeredNativeStruct).Type() = %q, want a native object type", tn) + } +} + +func TestNativeObjectCalculateSize(t *testing.T) { + env, err := cel.NewEnv( + ext.NativeTypes( + reflect.TypeOf(TestAllTypes{}), + reflect.TypeOf(TestNestedType{}), + reflect.TypeOf(TestEmbeddedPointerTypes{}), + ), + ) + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + adapter := env.CELTypeAdapter() + + tests := []struct { + name string + val any + want uint32 + }{ + { + name: "empty_struct", + val: &TestNestedType{}, + want: 1, // 1 (container) + }, + { + name: "nil_embedded_pointer", + val: &TestEmbeddedPointerTypes{}, + want: 1, // 1 (container); promoted fields through the nil embedded pointer count as unset + }, + { + name: "struct_with_scalar_and_list", + val: &TestNestedType{ + NestedListVal: []string{"a", "b", "c"}, + }, + want: 5, // 1 (root struct) + ["a", "b", "c"] (1 list container + 3 elements = 4) = 5 + }, + { + name: "struct_with_nested_map", + val: &TestNestedType{ + NestedMapVal: map[int64]bool{1: true, 2: false}, + }, + want: 6, // 1 (root struct) + map (1 container + (1+1) + (1+1) = 5) = 6 + }, + { + name: "nested_struct", + val: &TestAllTypes{ + StringVal: "hello", + NestedVal: &TestNestedType{ + NestedListVal: []string{"a", "b"}, + }, + }, + // 1 (root struct) + "hello"(1 unit) + NestedVal(1 container + ["a", "b"](1+2=3) = 4) = 6 + want: 6, + }, + { + name: "bytes_and_time", + val: &TestAllTypes{ + BytesVal: []byte("test"), + DurationVal: time.Second, + TimestampVal: time.Unix(100, 0), + }, + // 1 (root struct) + "test"(1 unit) + duration(1) + timestamp(1) = 4 + want: 4, + }, + { + name: "slice_of_structs", + val: &TestAllTypes{ + ListVal: []*TestNestedType{ + {NestedListVal: []string{"x"}}, + {NestedListVal: []string{"y", "z"}}, + }, + }, + want: 9, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + val := adapter.NativeToValue(tc.val) + sizer, ok := val.(types.AggregateSizeVisitor) + if !ok { + t.Fatalf("expected types.AggregateSizeVisitor implementation for %T", val) + } + if got := sizer.AggregateSize(types.NewSizeCalculator()); got != tc.want { + t.Errorf("got aggregate size %d, want %d", got, tc.want) + } + }) + } +} + +func BenchmarkNativeTypesEval(b *testing.B) { + benchmarks := []struct { + name string + expr string + in any + envOpts []any + }{ + { + name: "FieldAccess", + expr: "t.Int32Val + t.Int64Val", + in: map[string]any{ + "t": &TestAllTypes{Int32Val: 10, Int64Val: 20}, + }, + }, + { + name: "NestedFieldAccess", + expr: "t.NestedVal.NestedCustomName == 'name'", + in: map[string]any{ + "t": &TestAllTypes{ + NestedVal: &TestNestedType{NestedCustomName: "name"}, + }, + }, + }, + { + name: "StructCreation", + expr: `types_test.TestAllTypes{ + BoolVal: true, + Int32Val: 10, + Int64Val: 20, + StringVal: 'hello world', + }`, + }, + { + name: "FieldPresence", + expr: "has(t.BoolVal) && has(t.NestedVal)", + in: map[string]any{ + "t": &TestAllTypes{ + BoolVal: true, + NestedVal: &TestNestedType{}, + }, + }, + }, + { + name: "StructTagFieldAccess", + expr: "t.custom_name == 'name'", + envOpts: []any{types.ParseStructTags(true)}, + in: map[string]any{ + "t": &TestAllTypes{CustomName: "name"}, + }, + }, + { + name: "ListExists", + expr: "tests.exists(t, t.Int32Val > 15)", + in: map[string]any{ + "tests": []*TestAllTypes{ + {Int32Val: 10}, + {Int32Val: 20}, + }, + }, + }, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + envOpts := append([]any{ + cel.Variable("t", cel.ObjectType("types_test.TestAllTypes")), + }, bm.envOpts...) + env := testNativeEnv(b, envOpts...) + ast, iss := env.Compile(bm.expr) + if iss.Err() != nil { + b.Fatalf("env.Compile(%q) failed: %v", bm.expr, iss.Err()) + } + prg, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize)) + if err != nil { + b.Fatalf("env.Program() failed: %v", err) + } + input := bm.in + if input == nil { + input = cel.NoVars() + } + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + prg.Eval(input) + } + }) + } +} + +func BenchmarkNativeToValue(b *testing.B) { + env := testNativeEnv(b) + adapter := env.CELTypeAdapter() + + nested := &TestNestedType{ + NestedListVal: []string{"a", "b", "c"}, + NestedMapVal: map[int64]bool{1: true}, + NestedCustomName: "test", + } + allTypes := &TestAllTypes{ + BoolVal: true, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + NestedVal: nested, + ListVal: []*TestNestedType{nested}, + } + allTypesSlice := []*TestAllTypes{allTypes, allTypes} + + benchmarks := []struct { + name string + val any + }{ + {name: "TestNestedType", val: nested}, + {name: "TestAllTypes", val: allTypes}, + {name: "SliceTestAllTypes", val: allTypesSlice}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + adapter.NativeToValue(bm.val) + } + }) + } +} + +func BenchmarkConvertToNative(b *testing.B) { + env := testNativeEnv(b) + adapter := env.CELTypeAdapter() + + allTypes := &TestAllTypes{ + BoolVal: true, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + } + celVal := adapter.NativeToValue(allTypes) + targetType := reflect.TypeOf(&TestAllTypes{}) + + allTypesSlice := []*TestAllTypes{allTypes, allTypes} + celSliceVal := adapter.NativeToValue(allTypesSlice) + sliceTargetType := reflect.TypeOf([]*TestAllTypes{}) + + b.Run("TestAllTypes", func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := celVal.ConvertToNative(targetType) + if err != nil { + b.Fatalf("ConvertToNative failed: %v", err) + } + } + }) + + b.Run("SliceTestAllTypes", func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := celSliceVal.ConvertToNative(sliceTargetType) + if err != nil { + b.Fatalf("ConvertToNative failed: %v", err) + } + } + }) +} + +// testEnv initializes the test environment common to all tests. +func testNativeEnv(t testing.TB, opts ...any) *cel.Env { + t.Helper() + envOpts := []cel.EnvOption{ + cel.Container("types_test"), + cel.Abbrevs("google.expr.proto3.test"), + cel.Types(&proto3pb.TestAllTypes{}), + cel.Variable("tests", cel.ListType(cel.ObjectType("types_test.TestAllTypes"))), + } + nativeOpts := []any{ + reflect.ValueOf(&TestAllTypes{}), + reflect.ValueOf(&TestRefValFieldType{}), + } + for _, o := range opts { + switch opt := o.(type) { + case types.NativeTypeOption: + nativeOpts = append(nativeOpts, opt) + case cel.EnvOption: + envOpts = append(envOpts, opt) + default: + t.Fatalf("invalid option type: %s", reflect.TypeOf(o).Name()) + } + } + + envOpts = append(envOpts, + ext.NativeTypes( + nativeOpts..., + ), + ) + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv(NativeTypes()) failed: %v", err) + } + return env +} + +func mustParseTime(t *testing.T, timestamp string) time.Time { + t.Helper() + out, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + t.Fatalf("time.Parse(%q) failed: %v", timestamp, err) + } + return out +} + +type Custom struct { + Name string `cel:"name"` +} + +type TestStructWithMultipleSameNames struct { + Name string + CustomName string `cel:"Name"` +} + +type TestNestedType struct { + NestedListVal []string + NestedMapVal map[int64]bool + NestedCustomName string `cel:"custom_name" json:"custom_name"` +} + +type TestAllTypes struct { + NestedVal *TestNestedType `json:"nestedVal,omitempty"` + NestedStructVal TestNestedType `json:"nestedStructVal"` + BoolVal bool `json:"boolVal"` + BytesVal []byte + DurationVal time.Duration + DoubleVal float64 + FloatVal float32 + Int32Val int32 + Int64Val int64 + StringVal string + TimestampVal time.Time + Uint32Val uint32 + Uint64Val uint64 + ListVal []*TestNestedType + ArrayVal [1]*TestNestedType + BytesArrayVal [4]byte + MapVal map[string]TestAllTypes + PbVal *proto3pb.TestAllTypes + CustomSliceVal []TestNestedSliceType + CustomMapVal map[string]TestMapVal + CustomName string `cel:"custom_name"` + + // channel types are not supported + UnsupportedVal chan string + UnsupportedListVal []chan string + UnsupportedMapVal map[int]chan string + + // unexported types can be found but not set or accessed + privateVal map[string]string +} + +type TestNestedSliceType struct { + Value string +} + +type TestMapVal struct { + Value string +} + +type TestEmbeddedTypes struct { + Custom + TestNestedType `json:"embedded,omitempty"` + Skipped string `json:"-"` +} + +type TestEmbeddedPointerTypes struct { + *TestNestedType `json:"embedded,omitempty"` +} + +type TestRefValFieldType struct { + OptionalName *types.Optional `cel:"optional_name"` + IntVal types.Int + CELTime types.Timestamp `cel:"time"` +} + +// registeredNativeStruct is registered with NativeTypes in the delegation test. +type registeredNativeStruct struct { + Name string +} + +// unregisteredNativeStruct is not registered, so NativeToValue should hand it to +// the composed base adapter rather than wrapping it as a native object. +type unregisteredNativeStruct struct { + Name string +} + +// recordingAdapter converts unregisteredNativeStruct into a sentinel string and +// records that it was asked to, so the test can confirm nativeTypeProvider +// delegated the value. Everything else falls through to the base adapter. +type recordingAdapter struct { + base types.Adapter + saw bool +} + +func (a *recordingAdapter) NativeToValue(value any) ref.Val { + if _, ok := value.(unregisteredNativeStruct); ok { + a.saw = true + return types.String("from-base-adapter") + } + return a.base.NativeToValue(value) +} diff --git a/common/types/object.go b/common/types/object.go index d6dcac796..af4f17745 100644 --- a/common/types/object.go +++ b/common/types/object.go @@ -167,9 +167,17 @@ func (o *protoObj) Value() any { return o.value } -type protoObjField struct { - fd protoreflect.FieldDescriptor - v protoreflect.Value +// AggregateSize implements the AggregateSizeVisitor interface method. +func (o *protoObj) AggregateSize(sizer AggregateSizer) uint32 { + if o.value == nil { + return 0 + } + total := uint32(1) + o.value.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + total = safeAddUint32(total, sizer.AggregateSize(v)) + return true + }) + return total } func (o *protoObj) format(sb *strings.Builder) { diff --git a/common/types/object_test.go b/common/types/object_test.go index 18def0120..c496f83dd 100644 --- a/common/types/object_test.go +++ b/common/types/object_test.go @@ -257,3 +257,26 @@ func TestProtoObjectConvertToType(t *testing.T) { t.Error("identity type conversion failed") } } + +func TestProtoObjectCalculateSize(t *testing.T) { + msg := &exprpb.ParsedExpr{ + SourceInfo: &exprpb.SourceInfo{ + LineOffsets: []int32{1, 2, 3}, + }, + } + reg := newTestRegistry(t, ProtoTypeDefs(msg)) + objVal := reg.NativeToValue(msg) + sizer, ok := objVal.(AggregateSizeVisitor) + if !ok { + t.Fatalf("expected AggregateSizeVisitor implementation for protoObj") + } + // 1 (protoObj container) + SourceInfo field (1 container + 1 list container + 3 list elements = 5) = 6 + if got := sizer.AggregateSize(NewSizeCalculator()); got != 6 { + t.Errorf("got aggregate size %d, want 6", got) + } + + nilObj := &protoObj{} + if got := nilObj.AggregateSize(NewSizeCalculator()); got != 0 { + t.Errorf("got nil protoObj aggregate size %d, want 0", got) + } +} diff --git a/common/types/optional.go b/common/types/optional.go index 7c0418110..d38b45349 100644 --- a/common/types/optional.go +++ b/common/types/optional.go @@ -120,3 +120,11 @@ func (o *Optional) Value() any { } return o.value.Value() } + +// AggregateSize implements the AggregateSizeVisitor interface method. +func (o *Optional) AggregateSize(sizer AggregateSizer) uint32 { + if !o.HasValue() { + return 0 + } + return safeAddUint32(1, sizer.AggregateSize(o.value)) +} diff --git a/common/types/optional_test.go b/common/types/optional_test.go index faf1c2e6c..e0c6bb225 100644 --- a/common/types/optional_test.go +++ b/common/types/optional_test.go @@ -185,3 +185,22 @@ func TestOptionalValue(t *testing.T) { t.Errorf("OptionalNone.Value() got %v, wanted nil", OptionalNone.Value()) } } + +func TestOptionalCalculateSize(t *testing.T) { + calc := NewSizeCalculator() + none := OptionalNone + if sizer, ok := any(none).(AggregateSizeVisitor); !ok || sizer.AggregateSize(calc) != 0 { + t.Errorf("expected 0 for OptionalNone") + } + + someScalar := OptionalOf(Int(42)) + if sizer, ok := any(someScalar).(AggregateSizeVisitor); !ok || sizer.AggregateSize(calc) != 2 { + t.Errorf("got %d for OptionalOf(scalar), want 2", sizer.AggregateSize(calc)) + } + + l := NewRefValList(DefaultTypeAdapter, []ref.Val{Int(1), Int(2)}) + someList := OptionalOf(l) + if sizer, ok := any(someList).(AggregateSizeVisitor); !ok || sizer.AggregateSize(calc) != 4 { + t.Errorf("got %d for OptionalOf(list of 2), want 4", sizer.AggregateSize(calc)) + } +} diff --git a/common/types/overflow.go b/common/types/overflow.go index dcb66ef59..49b15377f 100644 --- a/common/types/overflow.go +++ b/common/types/overflow.go @@ -427,3 +427,24 @@ func uint64ToInt64Lossless(v uint64) (int64, bool) { i, err := uint64ToInt64Checked(v) return i, err == nil } + +func safeAddUint32(a, b uint32) uint32 { + if math.MaxUint32-a < b { + return math.MaxUint32 + } + return a + b +} + +func safeUint32FromInt(n int) uint32 { + if n < 0 || uint64(n) > math.MaxUint32 { + return math.MaxUint32 + } + return uint32(n) +} + +func safeUint32FromBoxedInt(v Int) uint32 { + if v < 0 || v > math.MaxUint32 { + return math.MaxUint32 + } + return uint32(v) +} diff --git a/common/types/provider.go b/common/types/provider.go index c76c1fefd..453e53aeb 100644 --- a/common/types/provider.go +++ b/common/types/provider.go @@ -15,8 +15,11 @@ package types import ( + "encoding/json" "fmt" + "maps" "reflect" + "sync/atomic" "time" "google.golang.org/protobuf/proto" @@ -54,11 +57,11 @@ type Provider interface { // Returns false if not found. FindStructType(structType string) (*Type, bool) - // FindStructFieldNames returns thet field names associated with the type, if the type + // FindStructFieldNames returns the field names associated with the type, if the type // is found. FindStructFieldNames(structType string) ([]string, bool) - // FieldStructFieldType returns the field type for a checked type value. Returns + // FindStructFieldType returns the field type for a checked type value. Returns // false if the field could not be found. FindStructFieldType(structType, fieldName string) (*FieldType, bool) @@ -88,15 +91,27 @@ type FieldType struct { // Registry provides type information for a set of registered types. type Registry struct { - revTypeMap map[string]*Type - pbdb *pb.Db + revTypeMap map[string]*Type + structTypes map[string]StructTypeDescriptor + reflectTypes map[reflect.Type]StructTypeDescriptor + shared atomic.Bool + pbdb *pb.Db + provider Provider + adapter Adapter + nativeOptions NativeTypeOptions } -// NewRegistry accepts a list of proto message instances and returns a type -// provider which can create new instances of the provided message or any -// message that proto depends upon in its FileDescriptor. -func NewRegistry(types ...proto.Message) (*Registry, error) { - return NewProtoRegistry(ProtoTypeDefs(types...)) +// NewRegistry accepts a list of proto message instances, ref.Type instances, or RegistryOption +// functions and returns a type provider. +func NewRegistry(types ...any) (*Registry, error) { + r, err := NewProtoRegistry() + if err != nil { + return nil, err + } + if err := registerTypeItems(r, types...); err != nil { + return nil, err + } + return r, nil } // RegistryOption configures the behavior of the registry. @@ -123,12 +138,20 @@ func ProtoTypeDefs(types ...proto.Message) RegistryOption { } } +// Types creates a RegistryOption which registers individual custom type references or descriptors with the registry. +func Types(types ...ref.Type) RegistryOption { + return func(r *Registry) (*Registry, error) { + err := r.RegisterType(types...) + if err != nil { + return nil, err + } + return r, nil + } +} + // NewProtoRegistry creates a proto-based registry with a set of configurable options. func NewProtoRegistry(opts ...RegistryOption) (*Registry, error) { - r := &Registry{ - revTypeMap: make(map[string]*Type), - pbdb: pb.NewDb(), - } + r := NewEmptyRegistry() err := r.RegisterType( BoolType, BytesType, @@ -164,21 +187,65 @@ func NewProtoRegistry(opts ...RegistryOption) (*Registry, error) { // NewEmptyRegistry returns a registry which is completely unconfigured. func NewEmptyRegistry() *Registry { return &Registry{ - revTypeMap: make(map[string]*Type), - pbdb: pb.NewDb(), + revTypeMap: make(map[string]*Type), + structTypes: make(map[string]StructTypeDescriptor), + reflectTypes: make(map[reflect.Type]StructTypeDescriptor), + pbdb: pb.NewDb(), } } +// ComposeTypes accepts a provider, adapter, and a list of types (ref.Type, proto.Message, protoreflect.FileDescriptor, or RegistryOption) +// and either: +// - Determines the provider and adapter are the same instance and a *Registry and registers the listed types via RegisterType or +// one of the other registration methods as appropriate. +// - Determines the provider and adapter are not the same, or not a *Registry and creates a new composed *Registry which references +// the new type information first and then proxies to the underlying provider and adapter methods as appropriate. +func ComposeTypes(provider Provider, adapter Adapter, types ...any) (Provider, Adapter, error) { + reg, isReg := provider.(*Registry) + aReg, isAdapterReg := adapter.(*Registry) + if isReg && isAdapterReg && reg == aReg { + if err := registerTypeItems(reg, types...); err != nil { + return nil, nil, err + } + return reg, reg, nil + } + + composedReg, err := NewRegistry(types...) + if err != nil { + return nil, nil, err + } + composedReg.provider = provider + composedReg.adapter = adapter + return composedReg, composedReg, nil +} + // Copy copies the current state of the registry into its own memory space. func (p *Registry) Copy() *Registry { - copy := &Registry{ - revTypeMap: make(map[string]*Type), - pbdb: p.pbdb.Copy(), + if p == nil { + return nil } - for k, v := range p.revTypeMap { - copy.revTypeMap[k] = v + p.shared.Store(true) + cpy := &Registry{ + revTypeMap: p.revTypeMap, + structTypes: p.structTypes, + reflectTypes: p.reflectTypes, + nativeOptions: p.nativeOptions, + pbdb: p.pbdb, + provider: p.provider, + adapter: p.adapter, + } + cpy.shared.Store(true) + return cpy +} + +func (p *Registry) ensureMutable() { + if p.shared.Load() { + p.revTypeMap = maps.Clone(p.revTypeMap) + p.structTypes = maps.Clone(p.structTypes) + p.reflectTypes = maps.Clone(p.reflectTypes) + p.pbdb = p.pbdb.Copy() + p.shared.Store(false) } - return copy } // JSONFieldNames returns whether json field names are enabled in this registry. @@ -191,6 +258,7 @@ func (p *Registry) WithJSONFieldNames(enabled bool) error { if enabled == p.pbdb.JSONFieldNames() { return nil } + p.ensureMutable() newDB := pb.NewDb(pb.JSONFieldNames(enabled)) files := p.pbdb.FileDescriptions() for _, fd := range files { @@ -207,6 +275,9 @@ func (p *Registry) WithJSONFieldNames(enabled bool) error { func (p *Registry) EnumValue(enumName string) ref.Val { enumVal, found := p.pbdb.DescribeEnum(enumName) if !found { + if p.provider != nil { + return p.provider.EnumValue(enumName) + } return NewErr("unknown enum name '%s'", enumName) } return Int(enumVal.Value()) @@ -217,70 +288,98 @@ func (p *Registry) EnumValue(enumName string) ref.Val { // // Deprecated: use FindStructFieldType func (p *Registry) FindFieldType(structType, fieldName string) (*ref.FieldType, bool) { - msgType, found := p.pbdb.DescribeType(structType) - if !found { - return nil, false + structType = sanitizeStructTypeName(structType) + if st, found := p.structTypes[structType]; found { + if ft, found := st.FindFieldType(fieldName); found { + exprType, err := TypeToExprType(ft.Type) + if err != nil { + return nil, false + } + return makeRefFieldType(exprType, ft.IsSet, ft.GetFrom, ft.IsJSONField), true + } } - field, found := msgType.FieldByName(fieldName) - if !found { - return nil, false + if msgType, found := p.pbdb.DescribeType(structType); found { + if field, found := msgType.FieldByName(fieldName); found { + return makeRefFieldType(field.CheckedType(), field.IsSet, field.GetFrom, p.pbdb.JSONFieldNames() && fieldName == field.JSONName()), true + } } - return &ref.FieldType{ - Type: field.CheckedType(), - IsSet: field.IsSet, - GetFrom: field.GetFrom, - IsJSONField: p.pbdb.JSONFieldNames() && fieldName == field.JSONName(), - }, true + if p.provider != nil { + if ft, ok := p.provider.FindStructFieldType(structType, fieldName); ok && ft != nil { + exprType, err := TypeToExprType(ft.Type) + if err != nil { + return nil, false + } + return makeRefFieldType(exprType, ft.IsSet, ft.GetFrom, ft.IsJSONField), true + } + } + return nil, false } // FindStructFieldNames returns the set of field names for the given struct type, // if the type exists in the registry. func (p *Registry) FindStructFieldNames(structType string) ([]string, bool) { - msgType, found := p.pbdb.DescribeType(structType) - if !found { - return []string{}, false + structType = sanitizeStructTypeName(structType) + if st, found := p.structTypes[structType]; found { + return st.FieldNames(), true + } + if msgType, found := p.pbdb.DescribeType(structType); found { + fieldMap := msgType.FieldMap() + fields := make([]string, len(fieldMap)) + idx := 0 + for f := range fieldMap { + fields[idx] = f + idx++ + } + return fields, true } - fieldMap := msgType.FieldMap() - fields := make([]string, len(fieldMap)) - idx := 0 - for f := range fieldMap { - fields[idx] = f - idx++ + if p.provider != nil { + return p.provider.FindStructFieldNames(structType) } - return fields, true + return []string{}, false } // FindStructFieldType returns the field type for a checked type value. Returns // false if the field could not be found. func (p *Registry) FindStructFieldType(structType, fieldName string) (*FieldType, bool) { - msgType, found := p.pbdb.DescribeType(structType) - if !found { - return nil, false + structType = sanitizeStructTypeName(structType) + if st, found := p.structTypes[structType]; found { + if ft, found := st.FindFieldType(fieldName); found { + return ft, true + } } - field, found := msgType.FieldByName(fieldName) - if !found { - return nil, false + if msgType, found := p.pbdb.DescribeType(structType); found { + if field, found := msgType.FieldByName(fieldName); found { + return &FieldType{ + Type: fieldDescToCELType(field), + IsSet: field.IsSet, + GetFrom: field.GetFrom, + IsJSONField: p.pbdb.JSONFieldNames() && fieldName == field.JSONName(), + }, true + } + } + if p.provider != nil { + return p.provider.FindStructFieldType(structType, fieldName) } - return &FieldType{ - Type: fieldDescToCELType(field), - IsSet: field.IsSet, - GetFrom: field.GetFrom, - IsJSONField: p.pbdb.JSONFieldNames() && fieldName == field.JSONName(), - }, true + return nil, false } // FindStructFieldDescription returns documentation for a field if available. // Returns false if the field could not be found. func (p *Registry) FindStructFieldDescription(structType, fieldName string) (string, bool) { - msgType, found := p.pbdb.DescribeType(structType) - if !found { - return "", false + structType = sanitizeStructTypeName(structType) + if msgType, found := p.pbdb.DescribeType(structType); found { + if field, found := msgType.FieldByName(fieldName); found { + return field.Documentation(), true + } } - field, found := msgType.FieldByName(fieldName) - if !found { - return "", false + if p.provider != nil { + if pd, ok := p.provider.(interface { + FindStructFieldDescription(string, string) (string, bool) + }); ok { + return pd.FindStructFieldDescription(structType, fieldName) + } } - return field.Documentation(), true + return "", false } // FindIdent takes a qualified identifier name and returns a ref.Val if one exists. @@ -291,6 +390,9 @@ func (p *Registry) FindIdent(identName string) (ref.Val, bool) { if enumVal, found := p.pbdb.DescribeEnum(identName); found { return Int(enumVal.Value()), true } + if p.provider != nil { + return p.provider.FindIdent(identName) + } return nil, false } @@ -298,17 +400,19 @@ func (p *Registry) FindIdent(identName string) (ref.Val, bool) { // // Deprecated: use FindStructType func (p *Registry) FindType(structType string) (*exprpb.Type, bool) { - if _, found := p.pbdb.DescribeType(structType); !found { - return nil, false + structType = sanitizeStructTypeName(structType) + if p.hasStructType(structType) { + return makeExprMessageType(structType), true } - if structType != "" && structType[0] == '.' { - structType = structType[1:] + if p.provider != nil { + if tp, ok := p.provider.(ref.TypeProvider); ok { + return tp.FindType(structType) + } + if _, ok := p.provider.FindStructType(structType); ok { + return makeExprMessageType(structType), true + } } - return &exprpb.Type{ - TypeKind: &exprpb.Type_Type{ - Type: &exprpb.Type{ - TypeKind: &exprpb.Type_MessageType{ - MessageType: structType}}}}, true + return nil, false } // FindStructType returns the Type give a qualified type name. @@ -319,13 +423,14 @@ func (p *Registry) FindType(structType string) (*exprpb.Type, bool) { // // Returns false if not found. func (p *Registry) FindStructType(structType string) (*Type, bool) { - if _, found := p.pbdb.DescribeType(structType); !found { - return nil, false + structType = sanitizeStructTypeName(structType) + if p.hasStructType(structType) { + return NewTypeTypeWithParam(NewObjectType(structType)), true } - if structType != "" && structType[0] == '.' { - structType = structType[1:] + if p.provider != nil { + return p.provider.FindStructType(structType) } - return NewTypeTypeWithParam(NewObjectType(structType)), true + return nil, false } // NewValue creates a new type value from a qualified name and map of field @@ -335,8 +440,15 @@ func (p *Registry) FindStructType(structType string) (*Type, bool) { // to convert the Val to the field's native type. If an error occurs during // conversion, the NewValue will be a types.Err. func (p *Registry) NewValue(structType string, fields map[string]ref.Val) ref.Val { + structType = sanitizeStructTypeName(structType) + if st, found := p.structTypes[structType]; found { + return st.NewValue(p, fields) + } td, found := p.pbdb.DescribeType(structType) if !found { + if p.provider != nil { + return p.provider.NewValue(structType, fields) + } return NewErr("unknown type '%s'", structType) } msg := td.New() @@ -355,6 +467,7 @@ func (p *Registry) NewValue(structType string, fields map[string]ref.Val) ref.Va // RegisterDescriptor registers the contents of a protocol buffer `FileDescriptor`. func (p *Registry) RegisterDescriptor(fileDesc protoreflect.FileDescriptor) error { + p.ensureMutable() fd, err := p.pbdb.RegisterDescriptor(fileDesc) if err != nil { return err @@ -364,6 +477,7 @@ func (p *Registry) RegisterDescriptor(fileDesc protoreflect.FileDescriptor) erro // RegisterMessage registers a protocol buffer message and its dependencies. func (p *Registry) RegisterMessage(message proto.Message) error { + p.ensureMutable() fd, err := p.pbdb.RegisterMessage(message) if err != nil { return err @@ -382,338 +496,430 @@ func (p *Registry) RegisterMessage(message proto.Message) error { // to CEL, even when they're not based on protobuf types. func (p *Registry) RegisterType(types ...ref.Type) error { for _, t := range types { - celType := maybeForeignType(t) existing, found := p.revTypeMap[t.TypeName()] - if !found { - p.revTypeMap[t.TypeName()] = celType + celType := maybeForeignType(t) + if found { + if !existing.IsEquivalentType(celType) { + return fmt.Errorf("type registration conflict. found: %v, input: %v", existing, celType) + } + if existing.traitMask != celType.traitMask { + return fmt.Errorf( + "type registered with conflicting traits: %v with traits %v, input: %v", + existing.TypeName(), existing.traitMask, celType.traitMask) + } continue } - if !existing.IsEquivalentType(celType) { - return fmt.Errorf("type registration conflict. found: %v, input: %v", existing, celType) - } - if existing.traitMask != celType.traitMask { - return fmt.Errorf( - "type registered with conflicting traits: %v with traits %v, input: %v", - existing.TypeName(), existing.traitMask, celType.traitMask) + + p.ensureMutable() + typeName := t.TypeName() + p.revTypeMap[typeName] = celType + if st, ok := t.(StructTypeDescriptor); ok { + // Conflicts are gated above so if we see a struct here, it's safe to register. + p.structTypes[typeName] = st + if rt := st.ReflectType(); rt != nil { + p.reflectTypes[rt] = st + if rt.Kind() == reflect.Ptr { + p.reflectTypes[rt.Elem()] = st + } else { + p.reflectTypes[reflect.PointerTo(rt)] = st + } + } } } return nil } -// NativeToValue converts various "native" types to ref.Val with this specific implementation -// providing support for custom proto-based types. -// -// This method should be the inverse of ref.Val.ConvertToNative. -func (p *Registry) NativeToValue(value any) ref.Val { - if val, found := nativeToValue(p, value); found { - return val - } - switch v := value.(type) { - case proto.Message: - typeName := string(v.ProtoReflect().Descriptor().FullName()) - td, found := p.pbdb.DescribeType(typeName) - if !found { - return NewErr("unknown type: '%s'", typeName) - } - unwrapped, isUnwrapped, err := td.MaybeUnwrap(v) - if err != nil { - return UnsupportedRefValConversionErr(v) - } - if isUnwrapped { - return p.NativeToValue(unwrapped) - } - typeVal, found := p.FindIdent(typeName) - if !found { - return NewErr("unknown type: '%s'", typeName) - } - return NewObject(p, td, typeVal, v) - case *pb.Map: - return NewProtoMap(p, v) - case protoreflect.List: - return NewProtoList(p, v) - case protoreflect.Message: - return p.NativeToValue(v.Interface()) - case protoreflect.Value: - return p.NativeToValue(v.Interface()) +// RegisterNativeType creates nativeType instances for the given reflect.Type and registers them. +func (p *Registry) RegisterNativeType(refType reflect.Type) error { + result, err := newNativeTypes(refType, p.nativeOptions.fieldNameHandler) + if err != nil { + return err } - return UnsupportedRefValConversionErr(value) -} - -func (p *Registry) registerAllTypes(fd *pb.FileDescription) error { - for _, typeName := range fd.GetTypeNames() { - // skip well-known type names since they're automatically sanitized - // during NewObjectType() calls. - if _, found := checkedWellKnowns[typeName]; found { - continue - } - err := p.RegisterType(NewObjectTypeValue(typeName)) - if err != nil { + for _, nt := range result { + if err := p.RegisterType(nt); err != nil { return err } } return nil } -func fieldDescToCELType(field *pb.FieldDescription) *Type { - if field.IsMap() { - return NewMapType( - singularFieldDescToCELType(field.KeyType), - singularFieldDescToCELType(field.ValueType)) - } - if field.IsList() { - return NewListType(singularFieldDescToCELType(field)) - } - return singularFieldDescToCELType(field) -} - -func singularFieldDescToCELType(field *pb.FieldDescription) *Type { - if field.IsMessage() { - return NewObjectType(string(field.Descriptor().Message().FullName())) +func (p *Registry) findStructDescriptorByReflectType(rt reflect.Type) (StructTypeDescriptor, bool) { + if rt == nil { + return nil, false } - if field.IsEnum() { - return IntType + if st, found := p.reflectTypes[rt]; found { + return st, true } - return ProtoCELPrimitives[field.ProtoKind()] -} - -// defaultTypeAdapter converts go native types to CEL values. -type defaultTypeAdapter struct{} - -var ( - // DefaultTypeAdapter adapts canonical CEL types from their equivalent Go values. - DefaultTypeAdapter = &defaultTypeAdapter{} -) - -// NativeToValue implements the ref.TypeAdapter interface. -func (a *defaultTypeAdapter) NativeToValue(value any) ref.Val { - if val, found := nativeToValue(a, value); found { - return val + if rt.Kind() == reflect.Ptr { + if st, found := p.reflectTypes[rt.Elem()]; found { + return st, true + } + } else { + if st, found := p.reflectTypes[reflect.PointerTo(rt)]; found { + return st, true + } } - return UnsupportedRefValConversionErr(value) + return nil, false } -// nativeToValue returns the converted (ref.Val, true) of a conversion is found, -// otherwise (nil, false) -func nativeToValue(a Adapter, value any) (ref.Val, bool) { +// NativeToValue converts various "native" types to ref.Val with this specific implementation +// providing support for custom proto-based types. +// +// This method should be the inverse of ref.Val.ConvertToNative. +func (p *Registry) NativeToValue(value any) ref.Val { switch v := value.(type) { case nil: - return NullValue, true + return NullValue case *Bool: if v != nil { - return *v, true + return *v } case *Bytes: if v != nil { - return *v, true + return *v } case *Double: if v != nil { - return *v, true + return *v } case *Int: if v != nil { - return *v, true + return *v } case *String: if v != nil { - return *v, true + return *v } case *Uint: if v != nil { - return *v, true + return *v } + case ref.Val: + return v case bool: - return Bool(v), true + return Bool(v) case int: - return Int(v), true + return Int(v) case int32: - return Int(v), true + return Int(v) case int64: - return Int(v), true + return Int(v) case uint: - return Uint(v), true + return Uint(v) case uint32: - return Uint(v), true + return Uint(v) case uint64: - return Uint(v), true + return Uint(v) case float32: - return Double(v), true + return Double(v) case float64: - return Double(v), true + return Double(v) case string: - return String(v), true + return String(v) case *dpb.Duration: - return Duration{Duration: v.AsDuration()}, true + return Duration{Duration: v.AsDuration()} case time.Duration: - return Duration{Duration: v}, true + return Duration{Duration: v} case *tpb.Timestamp: - return Timestamp{Time: v.AsTime()}, true + return Timestamp{Time: v.AsTime()} case time.Time: - return Timestamp{Time: v}, true + return Timestamp{Time: v} case *bool: if v != nil { - return Bool(*v), true + return Bool(*v) } case *float32: if v != nil { - return Double(*v), true + return Double(*v) } case *float64: if v != nil { - return Double(*v), true + return Double(*v) } case *int: if v != nil { - return Int(*v), true + return Int(*v) } case *int32: if v != nil { - return Int(*v), true + return Int(*v) } case *int64: if v != nil { - return Int(*v), true + return Int(*v) } case *string: if v != nil { - return String(*v), true + return String(*v) } case *uint: if v != nil { - return Uint(*v), true + return Uint(*v) } case *uint32: if v != nil { - return Uint(*v), true + return Uint(*v) } case *uint64: if v != nil { - return Uint(*v), true + return Uint(*v) + } + case json.Number: + if i, err := v.Int64(); err == nil { + return Int(i) + } + if f, err := v.Float64(); err == nil { + return Double(f) + } + case json.RawMessage: + var rawVal any + if err := json.Unmarshal(v, &rawVal); err == nil { + return p.NativeToValue(rawVal) } case []byte: - return Bytes(v), true + return Bytes(v) // specializations for common lists types. case []string: - return NewStringList(a, v), true + return NewStringList(p, v) case []ref.Val: - return NewRefValList(a, v), true + return NewRefValList(p, v) // specializations for common map types. case map[string]string: - return NewStringStringMap(a, v), true + return NewStringStringMap(p, v) case map[string]any: - return NewStringInterfaceMap(a, v), true + return NewStringInterfaceMap(p, v) case map[ref.Val]ref.Val: - return NewRefValMap(a, v), true + return NewRefValMap(p, v) // additional specializations may be added upon request / need. case *anypb.Any: if v == nil { - return UnsupportedRefValConversionErr(v), true + return UnsupportedRefValConversionErr(v) } unpackedAny, err := v.UnmarshalNew() if err != nil { - return NewErr("anypb.UnmarshalNew() failed for type %q: %v", v.GetTypeUrl(), err), true + return NewErr("anypb.UnmarshalNew() failed for type %q: %v", v.GetTypeUrl(), err) } - return a.NativeToValue(unpackedAny), true + return p.NativeToValue(unpackedAny) case *structpb.NullValue, structpb.NullValue: - return NullValue, true + return NullValue case *structpb.ListValue: - return NewJSONList(a, v), true + return NewJSONList(p, v) case *structpb.Struct: - return NewJSONStruct(a, v), true - case ref.Val: - return v, true + return NewJSONStruct(p, v) case protoreflect.EnumNumber: - return Int(v), true + return Int(v) case proto.Message: if v == nil { - return UnsupportedRefValConversionErr(v), true + return UnsupportedRefValConversionErr(v) } typeName := string(v.ProtoReflect().Descriptor().FullName()) - td, found := pb.DefaultDb.DescribeType(typeName) + pbdb := p.pbdb + if pbdb == nil { + pbdb = pb.DefaultDb + } + td, found := pbdb.DescribeType(typeName) if !found { - return nil, false + if p.adapter != nil { + return p.adapter.NativeToValue(value) + } + return NewErr("unknown type: '%s'", typeName) } - val, unwrapped, err := td.MaybeUnwrap(v) + unwrapped, isUnwrapped, err := td.MaybeUnwrap(v) if err != nil { - return UnsupportedRefValConversionErr(v), true + return UnsupportedRefValConversionErr(v) } - if !unwrapped { - return nil, false + if isUnwrapped { + return p.NativeToValue(unwrapped) } - return a.NativeToValue(val), true - // Note: dynamicpb.Message implements the proto.Message _and_ protoreflect.Message interfaces - // which means that this case must appear after handling a proto.Message type. + typeVal, found := p.FindIdent(typeName) + if !found { + return NewErr("unknown type: '%s'", typeName) + } + return NewObject(p, td, typeVal, v) + case *pb.Map: + return NewProtoMap(p, v) + case protoreflect.List: + return NewProtoList(p, v) case protoreflect.Message: - return a.NativeToValue(v.Interface()), true + return p.NativeToValue(v.Interface()) + case protoreflect.Value: + return p.NativeToValue(v.Interface()) default: - refValue := reflect.ValueOf(v) - if refValue.Kind() == reflect.Ptr { - if refValue.IsNil() { - return UnsupportedRefValConversionErr(v), true + rt := reflect.TypeOf(value) + if len(p.reflectTypes) > 0 { + if st, found := p.findStructDescriptorByReflectType(rt); found { + return st.Adapt(p, value) + } + } + refVal := reflect.ValueOf(v) + if refVal.Kind() == reflect.Ptr { + if refVal.IsNil() { + break } - refValue = refValue.Elem() + refVal = refVal.Elem() } - refKind := refValue.Kind() - switch refKind { + switch refVal.Kind() { case reflect.Array, reflect.Slice: - if refValue.Type().Elem() == reflect.TypeOf(byte(0)) { - if refValue.CanAddr() { - return Bytes(refValue.Bytes()), true + if refVal.Type().Elem() == reflect.TypeOf(byte(0)) { + if refVal.CanAddr() { + return Bytes(refVal.Bytes()) } - tmp := reflect.New(refValue.Type()) - tmp.Elem().Set(refValue) - return Bytes(tmp.Elem().Bytes()), true + tmp := reflect.New(refVal.Type()) + tmp.Elem().Set(refVal) + return Bytes(tmp.Elem().Bytes()) } - return NewDynamicList(a, v), true + return NewDynamicList(p, v) case reflect.Map: - return NewDynamicMap(a, v), true - // type aliases of primitive types cannot be asserted as that type, but rather need - // to be downcast to int32 before being converted to a CEL representation. + return NewDynamicMap(p, v) case reflect.Bool: - boolTupe := reflect.TypeOf(false) - return Bool(refValue.Convert(boolTupe).Interface().(bool)), true - case reflect.Int: - intType := reflect.TypeOf(int(0)) - return Int(refValue.Convert(intType).Interface().(int)), true - case reflect.Int8: - intType := reflect.TypeOf(int8(0)) - return Int(refValue.Convert(intType).Interface().(int8)), true - case reflect.Int16: - intType := reflect.TypeOf(int16(0)) - return Int(refValue.Convert(intType).Interface().(int16)), true - case reflect.Int32: - intType := reflect.TypeOf(int32(0)) - return Int(refValue.Convert(intType).Interface().(int32)), true - case reflect.Int64: - intType := reflect.TypeOf(int64(0)) - return Int(refValue.Convert(intType).Interface().(int64)), true - case reflect.Uint: - uintType := reflect.TypeOf(uint(0)) - return Uint(refValue.Convert(uintType).Interface().(uint)), true - case reflect.Uint8: - uintType := reflect.TypeOf(uint8(0)) - return Uint(refValue.Convert(uintType).Interface().(uint8)), true - case reflect.Uint16: - uintType := reflect.TypeOf(uint16(0)) - return Uint(refValue.Convert(uintType).Interface().(uint16)), true - case reflect.Uint32: - uintType := reflect.TypeOf(uint32(0)) - return Uint(refValue.Convert(uintType).Interface().(uint32)), true - case reflect.Uint64: - uintType := reflect.TypeOf(uint64(0)) - return Uint(refValue.Convert(uintType).Interface().(uint64)), true - case reflect.Float32: - doubleType := reflect.TypeOf(float32(0)) - return Double(refValue.Convert(doubleType).Interface().(float32)), true - case reflect.Float64: - doubleType := reflect.TypeOf(float64(0)) - return Double(refValue.Convert(doubleType).Interface().(float64)), true + return Bool(refVal.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return Int(refVal.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return Uint(refVal.Uint()) + case reflect.Float32, reflect.Float64: + return Double(refVal.Float()) case reflect.String: - stringType := reflect.TypeOf("") - return String(refValue.Convert(stringType).Interface().(string)), true + return String(refVal.String()) } } - return nil, false + if p.adapter != nil { + return p.adapter.NativeToValue(value) + } + return UnsupportedRefValConversionErr(value) +} + +func (p *Registry) registerAllTypes(fd *pb.FileDescription) error { + for _, typeName := range fd.GetTypeNames() { + // skip well-known type names since they're automatically sanitized + // during NewObjectType() calls. + if _, found := checkedWellKnowns[typeName]; found { + continue + } + err := p.RegisterType(NewObjectTypeValue(typeName)) + if err != nil { + return err + } + } + return nil +} + +func (p *Registry) hasStructType(structType string) bool { + if _, found := p.structTypes[structType]; found { + return true + } + _, found := p.pbdb.DescribeType(structType) + return found +} + +func sanitizeStructTypeName(structType string) string { + if len(structType) > 0 && structType[0] == '.' { + return structType[1:] + } + return structType +} + +func registerTypeItems(r *Registry, types ...any) error { + opts := make([]any, 0, len(types)) + items := make([]any, 0, len(types)) + for _, t := range types { + switch t.(type) { + case NativeTypeOption: + opts = append(opts, t) + default: + items = append(items, t) + } + } + for _, opt := range opts { + if err := registerTypeItem(r, opt); err != nil { + return err + } + } + for _, item := range items { + if err := registerTypeItem(r, item); err != nil { + return err + } + } + return nil +} + +func registerTypeItem(r *Registry, t any) error { + switch v := t.(type) { + case proto.Message: + return r.RegisterMessage(v) + case protoreflect.FileDescriptor: + return r.RegisterDescriptor(v) + case ref.Type: + return r.RegisterType(v) + case reflect.Type: + return r.RegisterNativeType(v) + case reflect.Value: + return r.RegisterNativeType(v.Type()) + case NativeTypeOption: + return v(&r.nativeOptions) + case RegistryOption: + _, err := v(r) + return err + default: + return fmt.Errorf("unsupported type: %v (%T) must be reflect.Type or reflect.Value", t, t) + } +} + +func makeExprMessageType(structType string) *exprpb.Type { + return &exprpb.Type{ + TypeKind: &exprpb.Type_Type{ + Type: &exprpb.Type{ + TypeKind: &exprpb.Type_MessageType{ + MessageType: structType, + }, + }, + }, + } +} + +func makeRefFieldType(t *exprpb.Type, isSet ref.FieldTester, getFrom ref.FieldGetter, isJSONField bool) *ref.FieldType { + return &ref.FieldType{ + Type: t, + IsSet: isSet, + GetFrom: getFrom, + IsJSONField: isJSONField, + } +} + +func fieldDescToCELType(field *pb.FieldDescription) *Type { + if field.IsMap() { + return NewMapType( + singularFieldDescToCELType(field.KeyType), + singularFieldDescToCELType(field.ValueType)) + } + if field.IsList() { + return NewListType(singularFieldDescToCELType(field)) + } + return singularFieldDescToCELType(field) +} + +func singularFieldDescToCELType(field *pb.FieldDescription) *Type { + if field.IsMessage() { + return NewObjectType(string(field.Descriptor().Message().FullName())) + } + if field.IsEnum() { + return IntType + } + return ProtoCELPrimitives[field.ProtoKind()] +} + +// defaultTypeAdapter converts go native types to CEL values. +type defaultTypeAdapter struct{} + +var ( + // DefaultTypeAdapter adapts canonical CEL types from their equivalent Go values. + DefaultTypeAdapter = &defaultTypeAdapter{} + emptyRegistry = &Registry{pbdb: pb.DefaultDb} +) + +// NativeToValue implements the ref.TypeAdapter interface. +func (a *defaultTypeAdapter) NativeToValue(value any) ref.Val { + return emptyRegistry.NativeToValue(value) } func msgSetField(target protoreflect.Message, field *pb.FieldDescription, val ref.Val) error { diff --git a/common/types/provider_test.go b/common/types/provider_test.go index 3f8fa206b..6e99c2374 100644 --- a/common/types/provider_test.go +++ b/common/types/provider_test.go @@ -16,10 +16,12 @@ package types import ( "bytes" + "encoding/json" "fmt" "reflect" "sort" "strings" + "sync" "testing" "time" @@ -37,48 +39,281 @@ import ( ) func TestRegistryCopy(t *testing.T) { + tests := []struct { + name string + reg *Registry + }{ + { + name: "empty", + reg: NewEmptyRegistry(), + }, + { + name: "populated", + reg: newTestRegistry(t), + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + reg2 := tc.reg.Copy() + if !reflect.DeepEqual(tc.reg, reg2) { + t.Fatal("type registry copy did not produce equivalent values.") + } + }) + } + + t.Run("nil registry", func(t *testing.T) { + var reg *Registry + if reg.Copy() != nil { + t.Error("expected nil registry copy to return nil") + } + }) +} + +func assertShared(t *testing.T, reg *Registry) { + t.Helper() + if !reg.shared.Load() { + t.Errorf("registry.shared = false, want true") + } +} + +func assertUnshared(t *testing.T, reg *Registry) { + t.Helper() + if reg.shared.Load() { + t.Errorf("registry.shared = true, want false") + } +} + +func newSharedRegistryPair(t *testing.T, opts ...RegistryOption) (*Registry, *Registry) { + t.Helper() + reg := newTestRegistry(t, opts...) + copied := reg.Copy() + assertShared(t, reg) + assertShared(t, copied) + return reg, copied +} + +func TestRegistrySharedOnCopy(t *testing.T) { reg := NewEmptyRegistry() - reg2 := reg.Copy() - if !reflect.DeepEqual(reg, reg2) { - t.Fatal("type registry copy did not produce equivalent values.") + assertUnshared(t, reg) + + copied := reg.Copy() + assertShared(t, reg) + assertShared(t, copied) + + if !reflect.DeepEqual(reg, copied) { + t.Errorf("reg.Copy() expected equivalent registries") + } +} + +func TestRegistryUnshared_RegisterTypeOnCopy(t *testing.T) { + reg, copied := newSharedRegistryPair(t) + + customType := NewObjectType("custom.TypeA") + if err := copied.RegisterType(customType); err != nil { + t.Fatalf("RegisterType() failed: %v", err) + } + + assertUnshared(t, copied) + assertShared(t, reg) + + if _, found := copied.FindIdent("custom.TypeA"); !found { + t.Errorf("copied.FindIdent('custom.TypeA') expected found == true") + } + if _, found := reg.FindIdent("custom.TypeA"); found { + t.Errorf("reg.FindIdent('custom.TypeA') expected found == false after mutating copy") + } + + // Subsequent mutation on already unshared copy stays unshared + customTypeB := NewObjectType("custom.TypeB") + if err := copied.RegisterType(customTypeB); err != nil { + t.Fatalf("RegisterType() failed: %v", err) + } + assertUnshared(t, copied) + if _, found := copied.FindIdent("custom.TypeB"); !found { + t.Errorf("copied.FindIdent('custom.TypeB') expected found == true") } - reg = newTestRegistry(t) - reg2 = reg.Copy() - if !reflect.DeepEqual(reg, reg2) { - t.Fatal("type registry copy did not produce equivalent values.") + if _, found := reg.FindIdent("custom.TypeB"); found { + t.Errorf("reg.FindIdent('custom.TypeB') expected found == false") } } -func TestRegistryRegisterType(t *testing.T) { - reg := newTestRegistry(t) - err := reg.RegisterType( - NewTypeValue("http.Request", traits.ReceiverType), - NewObjectType("http.Request", traits.ReceiverType), - ) - if err == nil { - t.Error("RegisterType() for differing type definitions with the same name did not fail") +func TestRegistryUnshared_RegisterTypeOnOriginal(t *testing.T) { + reg, copied := newSharedRegistryPair(t) + + customType := NewObjectType("custom.TypeOrig") + if err := reg.RegisterType(customType); err != nil { + t.Fatalf("RegisterType() failed: %v", err) + } + + assertUnshared(t, reg) + assertShared(t, copied) + + if _, found := reg.FindIdent("custom.TypeOrig"); !found { + t.Errorf("reg.FindIdent('custom.TypeOrig') expected found == true") + } + if _, found := copied.FindIdent("custom.TypeOrig"); found { + t.Errorf("copied.FindIdent('custom.TypeOrig') expected found == false after mutating original") } } -func TestRegistryRegisterTypeNoConflict(t *testing.T) { - reg := newTestRegistry(t) - err := reg.RegisterType( - NewOpaqueType("http.Request", NewTypeParamType("T")), - NewOpaqueType("http.Request", NewTypeParamType("V")), - ) +func TestRegistryUnshared_RegisterMessage(t *testing.T) { + reg, copied := newSharedRegistryPair(t) + + if err := copied.RegisterMessage(&proto3pb.TestAllTypes{}); err != nil { + t.Fatalf("RegisterMessage() failed: %v", err) + } + + assertUnshared(t, copied) + assertShared(t, reg) + + if _, found := copied.FindStructType("google.expr.proto3.test.TestAllTypes"); !found { + t.Errorf("copied.FindStructType() expected found == true") + } + if _, found := reg.FindStructType("google.expr.proto3.test.TestAllTypes"); found { + t.Errorf("reg.FindStructType() expected found == false") + } +} + +func TestRegistryUnshared_RegisterDescriptor(t *testing.T) { + reg, copied := newSharedRegistryPair(t) + + err := copied.RegisterDescriptor(proto3pb.GlobalEnum_GOO.Descriptor().ParentFile()) if err != nil { - t.Errorf("RegisterType() failed for equivalent types: %v", err) + t.Fatalf("RegisterDescriptor() failed: %v", err) + } + + assertUnshared(t, copied) + assertShared(t, reg) + + enumVal := copied.EnumValue("google.expr.proto3.test.GlobalEnum.GOO") + if IsError(enumVal) || enumVal.(Int) != Int(proto3pb.GlobalEnum_GOO.Number()) { + t.Errorf("copied.EnumValue() got %v, wanted %v", enumVal, proto3pb.GlobalEnum_GOO.Number()) + } + origEnumVal := reg.EnumValue("google.expr.proto3.test.GlobalEnum.GOO") + if !IsError(origEnumVal) { + t.Errorf("reg.EnumValue() expected error, got %v", origEnumVal) } } -func TestRegistryRegisterTypeConflict(t *testing.T) { - reg := newTestRegistry(t) - err := reg.RegisterType( - NewOpaqueType("http.Request", NewTypeParamType("T"), NewTypeParamType("V")), - NewOpaqueType("http.Request", NewTypeParamType("V")), - ) - if err == nil { - t.Error("RegisterType() for differing type definitions with the same name did not fail") +func TestRegistryUnshared_WithJSONFieldNames(t *testing.T) { + reg, copied := newSharedRegistryPair(t, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + + if err := copied.WithJSONFieldNames(true); err != nil { + t.Fatalf("WithJSONFieldNames() failed: %v", err) + } + + assertUnshared(t, copied) + assertShared(t, reg) + + if !copied.JSONFieldNames() { + t.Errorf("copied.JSONFieldNames() expected true, got false") + } + if reg.JSONFieldNames() { + t.Errorf("reg.JSONFieldNames() expected false, got true") + } +} + +func TestRegistryUnshared_ChainedCopies(t *testing.T) { + r1 := NewEmptyRegistry() + r2 := r1.Copy() + r3 := r2.Copy() + + assertShared(t, r1) + assertShared(t, r2) + assertShared(t, r3) + + typeInR2 := NewObjectType("custom.InR2") + if err := r2.RegisterType(typeInR2); err != nil { + t.Fatalf("RegisterType() failed: %v", err) + } + + assertUnshared(t, r2) + assertShared(t, r1) + assertShared(t, r3) + + if _, found := r2.FindIdent("custom.InR2"); !found { + t.Errorf("r2.FindIdent('custom.InR2') expected found == true") + } + if _, found := r1.FindIdent("custom.InR2"); found { + t.Errorf("r1.FindIdent('custom.InR2') expected found == false") + } + if _, found := r3.FindIdent("custom.InR2"); found { + t.Errorf("r3.FindIdent('custom.InR2') expected found == false") + } + + typeInR3 := NewObjectType("custom.InR3") + if err := r3.RegisterType(typeInR3); err != nil { + t.Fatalf("RegisterType() failed: %v", err) + } + + assertUnshared(t, r3) + if _, found := r3.FindIdent("custom.InR3"); !found { + t.Errorf("r3.FindIdent('custom.InR3') expected found == true") + } + if _, found := r1.FindIdent("custom.InR3"); found { + t.Errorf("r1.FindIdent('custom.InR3') expected found == false") + } + if _, found := r2.FindIdent("custom.InR3"); found { + t.Errorf("r2.FindIdent('custom.InR3') expected found == false") + } +} + +func TestRegistryConcurrentCopy(t *testing.T) { + reg := NewEmptyRegistry() + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = reg.Copy() + }() + } + wg.Wait() +} + +func TestRegistryRegisterType(t *testing.T) { + tests := []struct { + name string + types []ref.Type + wantErr bool + }{ + { + name: "differing type definitions same name", + types: []ref.Type{ + NewTypeValue("http.Request", traits.ReceiverType), + NewObjectType("http.Request", traits.ReceiverType), + }, + wantErr: true, + }, + { + name: "equivalent opaque types no conflict", + types: []ref.Type{ + NewOpaqueType("http.Request", NewTypeParamType("T")), + NewOpaqueType("http.Request", NewTypeParamType("V")), + }, + wantErr: false, + }, + { + name: "differing opaque types conflict", + types: []ref.Type{ + NewOpaqueType("http.Request", NewTypeParamType("T"), NewTypeParamType("V")), + NewOpaqueType("http.Request", NewTypeParamType("V")), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + reg := newTestRegistry(t) + err := reg.RegisterType(tc.types...) + if (err != nil) != tc.wantErr { + t.Errorf("RegisterType() error = %v, wantErr %v", err, tc.wantErr) + } + }) } } @@ -88,17 +323,24 @@ func TestRegistryEnumValue(t *testing.T) { if err != nil { t.Fatalf("RegisterDescriptor() failed: %v", err) } - enumVal := reg.EnumValue("google.expr.proto3.test.GlobalEnum.GOO") - if Int(proto3pb.GlobalEnum_GOO.Number()) != enumVal.(Int) { - t.Errorf("enum values were not equal between registry and proto: %v", enumVal) - } - enumVal2, found := reg.FindIdent("google.expr.proto3.test.GlobalEnum.GOO") - if !found { - t.Fatal("Ident not found google.expr.proto3.test.GlobalEnum.GOO") - } - if enumVal.(Int) != enumVal2.(Int) { - t.Errorf("got enum value %v, wanted %v", enumVal2, enumVal) - } + + t.Run("EnumValue", func(t *testing.T) { + enumVal := reg.EnumValue("google.expr.proto3.test.GlobalEnum.GOO") + if IsError(enumVal) || Int(proto3pb.GlobalEnum_GOO.Number()) != enumVal.(Int) { + t.Errorf("enum values were not equal between registry and proto: %v", enumVal) + } + }) + + t.Run("FindIdent", func(t *testing.T) { + enumVal := reg.EnumValue("google.expr.proto3.test.GlobalEnum.GOO") + enumVal2, found := reg.FindIdent("google.expr.proto3.test.GlobalEnum.GOO") + if !found { + t.Fatal("Ident not found google.expr.proto3.test.GlobalEnum.GOO") + } + if enumVal.(Int) != enumVal2.(Int) { + t.Errorf("got enum value %v, wanted %v", enumVal2, enumVal) + } + }) } func TestRegistryFindStructType(t *testing.T) { @@ -107,60 +349,80 @@ func TestRegistryFindStructType(t *testing.T) { if err != nil { t.Fatalf("RegisterDescriptor() failed: %v", err) } - msgTypeName := ".google.expr.proto3.test.TestAllTypes" - exprType, found := reg.FindType(msgTypeName) - if !found { - t.Fatalf("FindType() did not find: %q", msgTypeName) - } - celType, found := reg.FindStructType(msgTypeName) - if !found { - t.Fatalf("FindStructType() did not find %q", msgTypeName) - } - exprConvType, err := ExprTypeToType(exprType) - if err != nil { - t.Fatalf("ExprTypeToType(%v) failed: %v", exprType, err) - } - if !exprConvType.IsExactType(celType) { - t.Errorf("Got %v type, wanted %v", exprConvType, celType) - } - _, found = reg.FindType(msgTypeName + "Undefined") - if found { - t.Fatalf("FindType() found: %q", msgTypeName+"Undefined") + + tests := []struct { + typeName string + wantFound bool + }{ + { + typeName: ".google.expr.proto3.test.TestAllTypes", + wantFound: true, + }, + { + typeName: ".google.expr.proto3.test.TestAllTypesUndefined", + wantFound: false, + }, } - _, found = reg.FindStructType(msgTypeName + "Undefined") - if found { - t.Fatalf("FindStructType() found: %q", msgTypeName+"Undefined") + + for _, tt := range tests { + tc := tt + t.Run(tc.typeName, func(t *testing.T) { + exprType, foundType := reg.FindType(tc.typeName) + celType, foundStruct := reg.FindStructType(tc.typeName) + + if foundType != tc.wantFound { + t.Errorf("FindType(%q) found = %v, want %v", tc.typeName, foundType, tc.wantFound) + } + if foundStruct != tc.wantFound { + t.Errorf("FindStructType(%q) found = %v, want %v", tc.typeName, foundStruct, tc.wantFound) + } + + if tc.wantFound { + exprConvType, err := ExprTypeToType(exprType) + if err != nil { + t.Fatalf("ExprTypeToType(%v) failed: %v", exprType, err) + } + if !exprConvType.IsExactType(celType) { + t.Errorf("Got %v type, wanted %v", exprConvType, celType) + } + } + }) } } func TestRegistryFindStructFieldNames(t *testing.T) { tests := []struct { + name string typeName string fields []string jsonFieldNames bool }{ { + name: "Reference", typeName: "google.api.expr.v1alpha1.Reference", fields: []string{"name", "overload_id", "value"}, }, { + name: "Decl", typeName: "google.api.expr.v1alpha1.Decl", fields: []string{"name", "ident", "function"}, }, { + name: "invalid type", typeName: "invalid.TypeName", fields: []string{}, }, { + name: "Reference JSON field names", typeName: "google.api.expr.v1alpha1.Reference", fields: []string{"name", "overloadId", "value"}, jsonFieldNames: true, }, } - for _, tst := range tests { - tc := tst - t.Run(fmt.Sprintf("%s", tc.typeName), func(t *testing.T) { + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { reg := newTestRegistry(t, ProtoTypeDefs(&exprpb.Decl{}, &exprpb.Reference{}), JSONFieldNames(tc.jsonFieldNames)) @@ -192,11 +454,6 @@ func TestRegistryFindStructFieldType(t *testing.T) { field: "single_nested_message", found: true, }, - { - typeName: msgTypeName, - field: "single_nested_message", - found: true, - }, { typeName: msgTypeName, field: "standalone_enum", @@ -505,116 +762,145 @@ func TestRegistryNewValueErrors(t *testing.T) { func TestRegistryGetters(t *testing.T) { reg := newTestRegistry(t, ProtoTypeDefs(&exprpb.ParsedExpr{})) - if sourceInfo := reg.NewValue( + sourceInfo := reg.NewValue( "google.api.expr.v1alpha1.SourceInfo", map[string]ref.Val{ "location": String("TestTypeRegistryGetFieldValue"), "line_offsets": NewDynamicList(reg, []int64{0, 2}), "positions": NewDynamicMap(reg, map[int64]int64{1: 2, 2: 4}), - }); IsError(sourceInfo) { - t.Error(sourceInfo) - } else { - si := sourceInfo.(traits.Indexer) - if loc := si.Get(String("location")); IsError(loc) { - t.Error(loc) - } else if loc.(String) != "TestTypeRegistryGetFieldValue" { + }) + if IsError(sourceInfo) { + t.Fatalf("NewValue(SourceInfo) failed: %v", sourceInfo) + } + + si := sourceInfo.(traits.Indexer) + + t.Run("location", func(t *testing.T) { + loc := si.Get(String("location")) + if IsError(loc) { + t.Fatal(loc) + } + if loc.(String) != "TestTypeRegistryGetFieldValue" { t.Errorf("Expected %s, got %s", "TestTypeRegistryGetFieldValue", loc) } - if pos := si.Get(String("positions")); IsError(pos) { - t.Error(pos) - } else if pos.Equal(NewDynamicMap(reg, map[int64]int32{1: 2, 2: 4})) != True { + }) + + t.Run("positions", func(t *testing.T) { + pos := si.Get(String("positions")) + if IsError(pos) { + t.Fatal(pos) + } + if pos.Equal(NewDynamicMap(reg, map[int64]int32{1: 2, 2: 4})) != True { t.Errorf("Expected map[int64]int32, got %v", pos) - } else if posKeyVal := pos.(traits.Indexer).Get(Int(1)); IsError(posKeyVal) { - t.Error(posKeyVal) - } else if posKeyVal.(Int) != 2 { + } + posKeyVal := pos.(traits.Indexer).Get(Int(1)) + if IsError(posKeyVal) { + t.Fatal(posKeyVal) + } + if posKeyVal.(Int) != 2 { t.Error("Expected value to be int64, not int32") } - if offsets := si.Get(String("line_offsets")); IsError(offsets) { - t.Error(offsets) - } else if offset1 := offsets.(traits.Lister).Get(Int(1)); IsError(offset1) { - t.Error(offset1) - } else if offset1.(Int) != 2 { + }) + + t.Run("line_offsets", func(t *testing.T) { + offsets := si.Get(String("line_offsets")) + if IsError(offsets) { + t.Fatal(offsets) + } + offset1 := offsets.(traits.Lister).Get(Int(1)) + if IsError(offset1) { + t.Fatal(offset1) + } + if offset1.(Int) != 2 { t.Errorf("Expected index 1 to be value 2, was %v", offset1) } - } + }) } func TestConvertToNative(t *testing.T) { reg := newTestRegistry(t, ProtoTypeDefs(&exprpb.ParsedExpr{})) - - // Core type conversion tests. - expectValueToNative(t, True, true) - expectValueToNative(t, True, True) - expectValueToNative(t, NewDynamicList(reg, []Bool{True, False}), []any{true, false}) - expectValueToNative(t, NewDynamicList(reg, []Bool{True, False}), []ref.Val{True, False}) - expectValueToNative(t, Int(-1), int32(-1)) - expectValueToNative(t, Int(2), int64(2)) - expectValueToNative(t, Int(-1), Int(-1)) - expectValueToNative(t, NewDynamicList(reg, []Int{4}), []any{int64(4)}) - expectValueToNative(t, NewDynamicList(reg, []Int{5}), []ref.Val{Int(5)}) - expectValueToNative(t, Uint(3), uint32(3)) - expectValueToNative(t, Uint(4), uint64(4)) - expectValueToNative(t, Uint(5), Uint(5)) - expectValueToNative(t, NewDynamicList(reg, []Uint{4}), []any{uint64(4)}) - expectValueToNative(t, NewDynamicList(reg, []Uint{5}), []ref.Val{Uint(5)}) - expectValueToNative(t, Double(5.5), float32(5.5)) - expectValueToNative(t, Double(-5.5), float64(-5.5)) - expectValueToNative(t, NewDynamicList(reg, []Double{-5.5}), []any{-5.5}) - expectValueToNative(t, NewDynamicList(reg, []Double{-5.5}), []ref.Val{Double(-5.5)}) - expectValueToNative(t, Double(-5.5), Double(-5.5)) - expectValueToNative(t, String("hello"), "hello") - expectValueToNative(t, String("hello"), String("hello")) - expectValueToNative(t, NullValue, structpb.NullValue_NULL_VALUE) - expectValueToNative(t, NullValue, NullValue) - expectValueToNative(t, NewDynamicList(reg, []Null{NullValue}), []any{structpb.NullValue_NULL_VALUE}) - expectValueToNative(t, NewDynamicList(reg, []Null{NullValue}), []ref.Val{NullValue}) - expectValueToNative(t, Bytes("world"), []byte("world")) - expectValueToNative(t, Bytes("world"), Bytes("world")) - expectValueToNative(t, NewDynamicList(reg, []Bytes{Bytes("hello")}), []any{[]byte("hello")}) - expectValueToNative(t, NewDynamicList(reg, []Bytes{Bytes("hello")}), []ref.Val{Bytes("hello")}) - expectValueToNative(t, NewDynamicList(reg, []int64{1, 2, 3}), []int32{1, 2, 3}) - expectValueToNative(t, Duration{Duration: time.Duration(500)}, time.Duration(500)) - expectValueToNative(t, Duration{Duration: time.Duration(500)}, Duration{Duration: time.Duration(500)}) - expectValueToNative(t, Timestamp{Time: time.Unix(12345, 0)}, time.Unix(12345, 0)) - expectValueToNative(t, Timestamp{Time: time.Unix(12345, 0)}, Timestamp{Time: time.Unix(12345, 0)}) - expectValueToNative(t, NewDynamicMap(reg, - map[int64]int64{1: 1, 2: 1, 3: 1}), - map[int32]int32{1: 1, 2: 1, 3: 1}) - - // Null conversion tests. - expectValueToNative(t, Null(structpb.NullValue_NULL_VALUE), structpb.NullValue_NULL_VALUE) - - // Proto conversion tests. parsedExpr := &exprpb.ParsedExpr{} - expectValueToNative(t, reg.NativeToValue(parsedExpr), parsedExpr) - - // Custom scalars - expectValueToNative(t, Int(1), testInt(1)) - expectValueToNative(t, Int(1), testInt8(1)) - expectValueToNative(t, Int(1), testInt16(1)) - expectValueToNative(t, Int(1), testInt32(1)) - expectValueToNative(t, Int(1), testInt64(1)) - expectValueToNative(t, Uint(1), testUint(1)) - expectValueToNative(t, Uint(1), testUint8(1)) - expectValueToNative(t, Uint(1), testUint16(1)) - expectValueToNative(t, Uint(1), testUint32(1)) - expectValueToNative(t, Uint(1), testUint64(1)) - expectValueToNative(t, Double(4.5), testFloat32(4.5)) - expectValueToNative(t, Double(-5.1), testFloat64(-5.1)) - expectValueToNative(t, String("foo"), testString("foo")) + + tests := []struct { + name string + in ref.Val + want any + }{ + // Core type conversion tests. + {name: "bool to bool", in: True, want: true}, + {name: "bool to ref.Val Bool", in: True, want: True}, + {name: "bool list to []any", in: NewDynamicList(reg, []Bool{True, False}), want: []any{true, false}}, + {name: "bool list to []ref.Val", in: NewDynamicList(reg, []Bool{True, False}), want: []ref.Val{True, False}}, + {name: "int to int32", in: Int(-1), want: int32(-1)}, + {name: "int to int64", in: Int(2), want: int64(2)}, + {name: "int to ref.Val Int", in: Int(-1), want: Int(-1)}, + {name: "int list to []any", in: NewDynamicList(reg, []Int{4}), want: []any{int64(4)}}, + {name: "int list to []ref.Val", in: NewDynamicList(reg, []Int{5}), want: []ref.Val{Int(5)}}, + {name: "uint to uint32", in: Uint(3), want: uint32(3)}, + {name: "uint to uint64", in: Uint(4), want: uint64(4)}, + {name: "uint to ref.Val Uint", in: Uint(5), want: Uint(5)}, + {name: "uint list to []any", in: NewDynamicList(reg, []Uint{4}), want: []any{uint64(4)}}, + {name: "uint list to []ref.Val", in: NewDynamicList(reg, []Uint{5}), want: []ref.Val{Uint(5)}}, + {name: "double to float32", in: Double(5.5), want: float32(5.5)}, + {name: "double to float64", in: Double(-5.5), want: float64(-5.5)}, + {name: "double list to []any", in: NewDynamicList(reg, []Double{-5.5}), want: []any{-5.5}}, + {name: "double list to []ref.Val", in: NewDynamicList(reg, []Double{-5.5}), want: []ref.Val{Double(-5.5)}}, + {name: "double to ref.Val Double", in: Double(-5.5), want: Double(-5.5)}, + {name: "string to string", in: String("hello"), want: "hello"}, + {name: "string to ref.Val String", in: String("hello"), want: String("hello")}, + {name: "null to structpb.NullValue", in: NullValue, want: structpb.NullValue_NULL_VALUE}, + {name: "null to ref.Val NullValue", in: NullValue, want: NullValue}, + {name: "null list to []any", in: NewDynamicList(reg, []Null{NullValue}), want: []any{structpb.NullValue_NULL_VALUE}}, + {name: "null list to []ref.Val", in: NewDynamicList(reg, []Null{NullValue}), want: []ref.Val{NullValue}}, + {name: "bytes to []byte", in: Bytes("world"), want: []byte("world")}, + {name: "bytes to ref.Val Bytes", in: Bytes("world"), want: Bytes("world")}, + {name: "bytes list to []any", in: NewDynamicList(reg, []Bytes{Bytes("hello")}), want: []any{[]byte("hello")}}, + {name: "bytes list to []ref.Val", in: NewDynamicList(reg, []Bytes{Bytes("hello")}), want: []ref.Val{Bytes("hello")}}, + {name: "int64 list to []int32", in: NewDynamicList(reg, []int64{1, 2, 3}), want: []int32{1, 2, 3}}, + {name: "duration to time.Duration", in: Duration{Duration: time.Duration(500)}, want: time.Duration(500)}, + {name: "duration to ref.Val Duration", in: Duration{Duration: time.Duration(500)}, want: Duration{Duration: time.Duration(500)}}, + {name: "timestamp to time.Time", in: Timestamp{Time: time.Unix(12345, 0)}, want: time.Unix(12345, 0)}, + {name: "timestamp to ref.Val Timestamp", in: Timestamp{Time: time.Unix(12345, 0)}, want: Timestamp{Time: time.Unix(12345, 0)}}, + {name: "map[int64]int64 to map[int32]int32", in: NewDynamicMap(reg, map[int64]int64{1: 1, 2: 1, 3: 1}), want: map[int32]int32{1: 1, 2: 1, 3: 1}}, + + // Null conversion tests. + {name: "Null(NULL_VALUE) to structpb.NullValue", in: Null(structpb.NullValue_NULL_VALUE), want: structpb.NullValue_NULL_VALUE}, + + // Proto conversion tests. + {name: "parsedExpr proto to proto message", in: reg.NativeToValue(parsedExpr), want: parsedExpr}, + + // Custom scalars + {name: "int to testInt", in: Int(1), want: testInt(1)}, + {name: "int to testInt8", in: Int(1), want: testInt8(1)}, + {name: "int to testInt16", in: Int(1), want: testInt16(1)}, + {name: "int to testInt32", in: Int(1), want: testInt32(1)}, + {name: "int to testInt64", in: Int(1), want: testInt64(1)}, + {name: "uint to testUint", in: Uint(1), want: testUint(1)}, + {name: "uint to testUint8", in: Uint(1), want: testUint8(1)}, + {name: "uint to testUint16", in: Uint(1), want: testUint16(1)}, + {name: "uint to testUint32", in: Uint(1), want: testUint32(1)}, + {name: "uint to testUint64", in: Uint(1), want: testUint64(1)}, + {name: "double to testFloat32", in: Double(4.5), want: testFloat32(4.5)}, + {name: "double to testFloat64", in: Double(-5.1), want: testFloat64(-5.1)}, + {name: "string to testString", in: String("foo"), want: testString("foo")}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + expectValueToNative(t, tc.in, tc.want) + }) + } } func TestNativeToValue_Any(t *testing.T) { reg := newTestRegistry(t, ProtoTypeDefs(&exprpb.ParsedExpr{})) - // NullValue - anyValue, err := NullValue.ConvertToNative(anyValueType) + + nullAny, err := NullValue.ConvertToNative(anyValueType) if err != nil { - t.Error(err) + t.Fatalf("NullValue.ConvertToNative() failed: %v", err) } - expectNativeToValue(t, anyValue, NullValue) - // Json Struct - anyValue, err = anypb.New( + jsonStructAny, err := anypb.New( structpb.NewStructValue( &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -625,18 +911,10 @@ func TestNativeToValue_Any(t *testing.T) { ), ) if err != nil { - t.Error(err) + t.Fatalf("anypb.New(NewStructValue) failed: %v", err) } - expected := NewJSONStruct(reg, &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "a": structpb.NewStringValue("world"), - "b": structpb.NewStringValue("five!"), - }, - }) - expectNativeToValue(t, anyValue, expected) - //Json List - anyValue, err = anypb.New(structpb.NewListValue( + jsonListAny, err := anypb.New(structpb.NewListValue( &structpb.ListValue{ Values: []*structpb.Value{ structpb.NewStringValue("world"), @@ -645,184 +923,291 @@ func TestNativeToValue_Any(t *testing.T) { }, )) if err != nil { - t.Error(err) + t.Fatalf("anypb.New(NewListValue) failed: %v", err) } - expectedList := NewJSONList(reg, &structpb.ListValue{ - Values: []*structpb.Value{ - structpb.NewStringValue("world"), - structpb.NewStringValue("five!"), - }}) - expectNativeToValue(t, anyValue, expectedList) - // Object pbMessage := exprpb.ParsedExpr{ SourceInfo: &exprpb.SourceInfo{ - LineOffsets: []int32{1, 2, 3}}} - anyValue, err = anypb.New(&pbMessage) + LineOffsets: []int32{1, 2, 3}, + }, + } + pbMessageAny, err := anypb.New(&pbMessage) if err != nil { - t.Error(err) + t.Fatalf("anypb.New(ParsedExpr) failed: %v", err) + } + + tests := []struct { + name string + in any + want ref.Val + wantErr bool + }{ + { + name: "NullValue", + in: nullAny, + want: NullValue, + }, + { + name: "JSON Struct", + in: jsonStructAny, + want: NewJSONStruct(reg, &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "a": structpb.NewStringValue("world"), + "b": structpb.NewStringValue("five!"), + }, + }), + }, + { + name: "JSON List", + in: jsonListAny, + want: NewJSONList(reg, &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("world"), + structpb.NewStringValue("five!"), + }, + }), + }, + { + name: "Proto Message", + in: pbMessageAny, + want: reg.NativeToValue(&pbMessage), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + expectNativeToValue(t, tc.in, tc.want) + }) } - expectNativeToValue(t, anyValue, reg.NativeToValue(&pbMessage)) } func TestNativeToValue_Json(t *testing.T) { reg := newTestRegistry(t, ProtoTypeDefs(&exprpb.ParsedExpr{})) - // Json primitive conversion test. - expectNativeToValue(t, structpb.NewBoolValue(false), False) - expectNativeToValue(t, structpb.NewNumberValue(1.1), Double(1.1)) - expectNativeToValue(t, structpb.NewNullValue(), Null(structpb.NullValue_NULL_VALUE)) - expectNativeToValue(t, structpb.NewStringValue("hello"), String("hello")) - - // Json list conversion. - expectNativeToValue(t, - structpb.NewListValue( - &structpb.ListValue{ + parsedExpr := &exprpb.ParsedExpr{} + + tests := []struct { + name string + in any + want ref.Val + wantErr bool + }{ + // Json primitive conversion test. + {name: "bool value", in: structpb.NewBoolValue(false), want: False}, + {name: "number value", in: structpb.NewNumberValue(1.1), want: Double(1.1)}, + {name: "null value", in: structpb.NewNullValue(), want: Null(structpb.NullValue_NULL_VALUE)}, + {name: "string value", in: structpb.NewStringValue("hello"), want: String("hello")}, + + // Json list conversion. + { + name: "list value", + in: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("world"), + structpb.NewStringValue("five!"), + }, + }, + ), + want: NewJSONList(reg, &structpb.ListValue{ Values: []*structpb.Value{ structpb.NewStringValue("world"), structpb.NewStringValue("five!"), }, - }, - ), - NewJSONList(reg, &structpb.ListValue{ - Values: []*structpb.Value{ - structpb.NewStringValue("world"), - structpb.NewStringValue("five!"), - }, - })) + }), + }, - // Json struct conversion. - expectNativeToValue(t, - structpb.NewStructValue( - &structpb.Struct{ + // Json struct conversion. + { + name: "struct value", + in: structpb.NewStructValue( + &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "a": structpb.NewStringValue("world"), + "b": structpb.NewStringValue("five!"), + }, + }, + ), + want: NewJSONStruct(reg, &structpb.Struct{ Fields: map[string]*structpb.Value{ "a": structpb.NewStringValue("world"), "b": structpb.NewStringValue("five!"), }, - }, - ), - NewJSONStruct(reg, &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "a": structpb.NewStringValue("world"), - "b": structpb.NewStringValue("five!"), - }, - })) + }), + }, - // Proto conversion test. - parsedExpr := &exprpb.ParsedExpr{} - expectNativeToValue(t, parsedExpr, reg.NativeToValue(parsedExpr)) + // Proto conversion test. + { + name: "proto message", + in: parsedExpr, + want: reg.NativeToValue(parsedExpr), + }, + + // Go json.Number conversion. + {name: "json.Number int", in: json.Number("42"), want: Int(42)}, + {name: "json.Number float", in: json.Number("42.5"), want: Double(42.5)}, + {name: "json.Number invalid", in: json.Number("invalid-num"), wantErr: true}, + + // Go json.RawMessage conversion. + {name: "json.RawMessage map", in: json.RawMessage(`{"key":"value"}`), want: NewStringInterfaceMap(reg, map[string]any{"key": "value"})}, + {name: "json.RawMessage string", in: json.RawMessage(`"hello"`), want: String("hello")}, + {name: "json.RawMessage int", in: json.RawMessage(`123`), want: Double(123)}, + {name: "json.RawMessage array", in: json.RawMessage(`["world", 42]`), want: NewDynamicList(reg, []any{"world", float64(42)})}, + {name: "[]json.RawMessage slice", in: []json.RawMessage{json.RawMessage(`"hello"`), json.RawMessage(`123`)}, want: NewDynamicList(reg, []json.RawMessage{json.RawMessage(`"hello"`), json.RawMessage(`123`)})}, + {name: "json.RawMessage invalid", in: json.RawMessage(`invalid-json`), wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.wantErr { + reg := newTestRegistry(t, ProtoTypeDefs(&exprpb.ParsedExpr{})) + val := reg.NativeToValue(tc.in) + if !IsError(val) { + t.Errorf("NativeToValue(%v) = %v, want error", tc.in, val) + } + return + } + expectNativeToValue(t, tc.in, tc.want) + }) + } } func TestNativeToValue_Wrappers(t *testing.T) { - // Wrapper conversion test. - expectNativeToValue(t, wrapperspb.Bool(true), True) - expectNativeToValue(t, &wrapperspb.BoolValue{}, False) - expectNativeToValue(t, (*wrapperspb.BoolValue)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.BytesValue{}, Bytes{}) - expectNativeToValue(t, wrapperspb.Bytes([]byte("hi")), Bytes("hi")) - expectNativeToValue(t, (*wrapperspb.BytesValue)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.DoubleValue{}, Double(0.0)) - expectNativeToValue(t, wrapperspb.Double(6.4), Double(6.4)) - expectNativeToValue(t, (*wrapperspb.DoubleValue)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.FloatValue{}, Double(0.0)) - expectNativeToValue(t, wrapperspb.Float(3.0), Double(3.0)) - expectNativeToValue(t, (*wrapperspb.FloatValue)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.Int32Value{}, IntZero) - expectNativeToValue(t, wrapperspb.Int32(-32), Int(-32)) - expectNativeToValue(t, (*wrapperspb.Int32Value)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.Int64Value{}, IntZero) - expectNativeToValue(t, wrapperspb.Int64(-64), Int(-64)) - expectNativeToValue(t, (*wrapperspb.Int64Value)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.StringValue{}, String("")) - expectNativeToValue(t, wrapperspb.String("hello"), String("hello")) - expectNativeToValue(t, (*wrapperspb.StringValue)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.UInt32Value{}, Uint(0)) - expectNativeToValue(t, wrapperspb.UInt32(32), Uint(32)) - expectNativeToValue(t, (*wrapperspb.UInt32Value)(nil), NullValue) - expectNativeToValue(t, &wrapperspb.UInt64Value{}, Uint(0)) - expectNativeToValue(t, wrapperspb.UInt64(64), Uint(64)) - expectNativeToValue(t, (*wrapperspb.UInt64Value)(nil), NullValue) + tests := []struct { + name string + in any + want ref.Val + }{ + {name: "bool wrapper true", in: wrapperspb.Bool(true), want: True}, + {name: "bool wrapper zero value", in: &wrapperspb.BoolValue{}, want: False}, + {name: "bool wrapper nil", in: (*wrapperspb.BoolValue)(nil), want: NullValue}, + {name: "bytes wrapper zero value", in: &wrapperspb.BytesValue{}, want: Bytes{}}, + {name: "bytes wrapper value", in: wrapperspb.Bytes([]byte("hi")), want: Bytes("hi")}, + {name: "bytes wrapper nil", in: (*wrapperspb.BytesValue)(nil), want: NullValue}, + {name: "double wrapper zero value", in: &wrapperspb.DoubleValue{}, want: Double(0.0)}, + {name: "double wrapper value", in: wrapperspb.Double(6.4), want: Double(6.4)}, + {name: "double wrapper nil", in: (*wrapperspb.DoubleValue)(nil), want: NullValue}, + {name: "float wrapper zero value", in: &wrapperspb.FloatValue{}, want: Double(0.0)}, + {name: "float wrapper value", in: wrapperspb.Float(3.0), want: Double(3.0)}, + {name: "float wrapper nil", in: (*wrapperspb.FloatValue)(nil), want: NullValue}, + {name: "int32 wrapper zero value", in: &wrapperspb.Int32Value{}, want: IntZero}, + {name: "int32 wrapper value", in: wrapperspb.Int32(-32), want: Int(-32)}, + {name: "int32 wrapper nil", in: (*wrapperspb.Int32Value)(nil), want: NullValue}, + {name: "int64 wrapper zero value", in: &wrapperspb.Int64Value{}, want: IntZero}, + {name: "int64 wrapper value", in: wrapperspb.Int64(-64), want: Int(-64)}, + {name: "int64 wrapper nil", in: (*wrapperspb.Int64Value)(nil), want: NullValue}, + {name: "string wrapper zero value", in: &wrapperspb.StringValue{}, want: String("")}, + {name: "string wrapper value", in: wrapperspb.String("hello"), want: String("hello")}, + {name: "string wrapper nil", in: (*wrapperspb.StringValue)(nil), want: NullValue}, + {name: "uint32 wrapper zero value", in: &wrapperspb.UInt32Value{}, want: Uint(0)}, + {name: "uint32 wrapper value", in: wrapperspb.UInt32(32), want: Uint(32)}, + {name: "uint32 wrapper nil", in: (*wrapperspb.UInt32Value)(nil), want: NullValue}, + {name: "uint64 wrapper zero value", in: &wrapperspb.UInt64Value{}, want: Uint(0)}, + {name: "uint64 wrapper value", in: wrapperspb.UInt64(64), want: Uint(64)}, + {name: "uint64 wrapper nil", in: (*wrapperspb.UInt64Value)(nil), want: NullValue}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + expectNativeToValue(t, tc.in, tc.want) + }) + } } func TestNativeToValue_Primitive(t *testing.T) { reg := newTestRegistry(t) - // Core type conversions. - expectNativeToValue(t, true, True) - expectNativeToValue(t, int(-10), Int(-10)) - expectNativeToValue(t, int32(-1), Int(-1)) - expectNativeToValue(t, int64(2), Int(2)) - expectNativeToValue(t, uint(6), Uint(6)) - expectNativeToValue(t, uint32(3), Uint(3)) - expectNativeToValue(t, uint64(4), Uint(4)) - expectNativeToValue(t, float32(5.5), Double(5.5)) - expectNativeToValue(t, float64(-5.5), Double(-5.5)) - expectNativeToValue(t, "hello", String("hello")) - expectNativeToValue(t, []byte("world"), Bytes("world")) - expectNativeToValue(t, [4]byte{1, 2, 3, 4}, Bytes([]byte{1, 2, 3, 4})) - expectNativeToValue(t, &[4]byte{1, 2, 3, 4}, Bytes([]byte{1, 2, 3, 4})) - expectNativeToValue(t, time.Duration(500), Duration{Duration: time.Duration(500)}) - expectNativeToValue(t, time.Unix(12345, 0), Timestamp{Time: time.Unix(12345, 0)}) - expectNativeToValue(t, dpb.New(time.Duration(500)), Duration{Duration: time.Duration(500)}) - expectNativeToValue(t, tpb.New(time.Unix(12345, 0)), Timestamp{Time: time.Unix(12345, 0)}) - expectNativeToValue(t, []int32{1, 2, 3}, NewDynamicList(reg, []int32{1, 2, 3})) - expectNativeToValue(t, map[int32]int32{1: 1, 2: 1, 3: 1}, - NewDynamicMap(reg, map[int32]int32{1: 1, 2: 1, 3: 1})) - - // Pointers to core types. pBool := true - expectNativeToValue(t, &pBool, True) pDub32 := float32(2.5) pDub64 := float64(-1000.2) - expectNativeToValue(t, &pDub32, Double(2.5)) - expectNativeToValue(t, &pDub64, Double(-1000.2)) pInt := int(1) pInt32 := int32(2) pInt64 := int64(-1000) - expectNativeToValue(t, &pInt, Int(1)) - expectNativeToValue(t, &pInt32, Int(2)) - expectNativeToValue(t, &pInt64, Int(-1000)) pStr := "hello" - expectNativeToValue(t, &pStr, String("hello")) pUint := uint(1) pUint32 := uint32(2) pUint64 := uint64(1000) - expectNativeToValue(t, &pUint, Uint(1)) - expectNativeToValue(t, &pUint32, Uint(2)) - expectNativeToValue(t, &pUint64, Uint(1000)) - // Pointers to ref.Val extensions of core types. rBool := True - expectNativeToValue(t, &rBool, True) rDub := Double(32.1) - expectNativeToValue(t, &rDub, rDub) rInt := Int(-12) - expectNativeToValue(t, &rInt, rInt) rStr := String("hello") - expectNativeToValue(t, &rStr, rStr) rUint := Uint(12405) - expectNativeToValue(t, &rUint, rUint) rBytes := Bytes([]byte("hello")) - expectNativeToValue(t, &rBytes, rBytes) - - // Extensions to core types. - expectNativeToValue(t, testInt(1), Int(1)) - expectNativeToValue(t, testInt8(1), Int(1)) - expectNativeToValue(t, testInt16(1), Int(1)) - expectNativeToValue(t, testInt32(1), Int(1)) - expectNativeToValue(t, testInt64(-100), Int(-100)) - expectNativeToValue(t, testUint(1), Uint(1)) - expectNativeToValue(t, testUint8(1), Uint(1)) - expectNativeToValue(t, testUint16(1), Uint(1)) - expectNativeToValue(t, testUint32(2), Uint(2)) - expectNativeToValue(t, testUint64(3), Uint(3)) - expectNativeToValue(t, testFloat32(4.5), Double(4.5)) - expectNativeToValue(t, testFloat64(-5.1), Double(-5.1)) - expectNativeToValue(t, testString("foo"), String("foo")) - - // Null conversion test. - expectNativeToValue(t, nil, NullValue) - expectNativeToValue(t, structpb.NullValue_NULL_VALUE, Null(structpb.NullValue_NULL_VALUE)) -} + + tests := []struct { + name string + in any + want ref.Val + }{ + // Core type conversions. + {name: "bool", in: true, want: True}, + {name: "int", in: int(-10), want: Int(-10)}, + {name: "int32", in: int32(-1), want: Int(-1)}, + {name: "int64", in: int64(2), want: Int(2)}, + {name: "uint", in: uint(6), want: Uint(6)}, + {name: "uint32", in: uint32(3), want: Uint(3)}, + {name: "uint64", in: uint64(4), want: Uint(4)}, + {name: "float32", in: float32(5.5), want: Double(5.5)}, + {name: "float64", in: float64(-5.5), want: Double(-5.5)}, + {name: "string", in: "hello", want: String("hello")}, + {name: "bytes slice", in: []byte("world"), want: Bytes("world")}, + {name: "bytes array", in: [4]byte{1, 2, 3, 4}, want: Bytes([]byte{1, 2, 3, 4})}, + {name: "bytes array pointer", in: &[4]byte{1, 2, 3, 4}, want: Bytes([]byte{1, 2, 3, 4})}, + {name: "time duration", in: time.Duration(500), want: Duration{Duration: time.Duration(500)}}, + {name: "time timestamp", in: time.Unix(12345, 0), want: Timestamp{Time: time.Unix(12345, 0)}}, + {name: "proto duration", in: dpb.New(time.Duration(500)), want: Duration{Duration: time.Duration(500)}}, + {name: "proto timestamp", in: tpb.New(time.Unix(12345, 0)), want: Timestamp{Time: time.Unix(12345, 0)}}, + {name: "slice of int32", in: []int32{1, 2, 3}, want: NewDynamicList(reg, []int32{1, 2, 3})}, + {name: "map of int32", in: map[int32]int32{1: 1, 2: 1, 3: 1}, want: NewDynamicMap(reg, map[int32]int32{1: 1, 2: 1, 3: 1})}, + + // Pointers to core types. + {name: "pointer to bool", in: &pBool, want: True}, + {name: "pointer to float32", in: &pDub32, want: Double(2.5)}, + {name: "pointer to float64", in: &pDub64, want: Double(-1000.2)}, + {name: "pointer to int", in: &pInt, want: Int(1)}, + {name: "pointer to int32", in: &pInt32, want: Int(2)}, + {name: "pointer to int64", in: &pInt64, want: Int(-1000)}, + {name: "pointer to string", in: &pStr, want: String("hello")}, + {name: "pointer to uint", in: &pUint, want: Uint(1)}, + {name: "pointer to uint32", in: &pUint32, want: Uint(2)}, + {name: "pointer to uint64", in: &pUint64, want: Uint(1000)}, + + // Pointers to ref.Val extensions of core types. + {name: "pointer to ref.Val bool", in: &rBool, want: True}, + {name: "pointer to ref.Val double", in: &rDub, want: rDub}, + {name: "pointer to ref.Val int", in: &rInt, want: rInt}, + {name: "pointer to ref.Val string", in: &rStr, want: rStr}, + {name: "pointer to ref.Val uint", in: &rUint, want: rUint}, + {name: "pointer to ref.Val bytes", in: &rBytes, want: rBytes}, + + // Extensions to core types. + {name: "custom testBool", in: testBool(true), want: True}, + {name: "custom testInt", in: testInt(1), want: Int(1)}, + {name: "custom testInt8", in: testInt8(1), want: Int(1)}, + {name: "custom testInt16", in: testInt16(1), want: Int(1)}, + {name: "custom testInt32", in: testInt32(1), want: Int(1)}, + {name: "custom testInt64", in: testInt64(-100), want: Int(-100)}, + {name: "custom testUint", in: testUint(1), want: Uint(1)}, + {name: "custom testUint8", in: testUint8(1), want: Uint(1)}, + {name: "custom testUint16", in: testUint16(1), want: Uint(1)}, + {name: "custom testUint32", in: testUint32(2), want: Uint(2)}, + {name: "custom testUint64", in: testUint64(3), want: Uint(3)}, + {name: "custom testFloat32", in: testFloat32(4.5), want: Double(4.5)}, + {name: "custom testFloat64", in: testFloat64(-5.1), want: Double(-5.1)}, + {name: "custom testString", in: testString("foo"), want: String("foo")}, + + // Null conversion test. + {name: "nil", in: nil, want: NullValue}, + {name: "proto null value", in: structpb.NullValue_NULL_VALUE, want: Null(structpb.NullValue_NULL_VALUE)}, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + expectNativeToValue(t, tc.in, tc.want) + }) + } +} func TestUnsupportedConversion(t *testing.T) { reg := newTestRegistry(t) @@ -868,25 +1253,45 @@ func expectNativeToValue(t *testing.T, in any, out ref.Val) { } func BenchmarkNativeToValue(b *testing.B) { - reg, err := NewRegistry() + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) if err != nil { b.Fatalf("NewRegistry() failed: %v", err) } - inputs := []any{ - true, - false, - float32(-1.2), - float64(-2.4), - 1, - int32(2), - int64(3), - "", - "hello", - String("hello world"), - } - for _, in := range inputs { - input := in - b.Run(fmt.Sprintf("%T/%v", in, in), func(b *testing.B) { + + dummyDesc := &testDummyStructDescriptor{ + Type: NewObjectType("dummy.Struct"), + reflectType: reflect.TypeOf(dummyNativeStruct{}), + fieldType: &FieldType{Type: StringType}, + } + if err := reg.RegisterType(dummyDesc); err != nil { + b.Fatalf("RegisterType() failed: %v", err) + } + + protoMsg := &proto3pb.TestAllTypes{SingleInt32: 42} + nativeStructVal := dummyNativeStruct{} + nativeStructPtr := &dummyNativeStruct{} + + inputs := []struct { + name string + val any + }{ + {name: "bool/true", val: true}, + {name: "int/1", val: 1}, + {name: "int64/3", val: int64(3)}, + {name: "string/hello", val: "hello"}, + {name: "ref.Val/String", val: String("hello world")}, + {name: "ref.Val/Int", val: Int(42)}, + {name: "ref.Val/Bool", val: Bool(true)}, + {name: "proto/TestAllTypes", val: protoMsg}, + {name: "nativeStruct/value", val: nativeStructVal}, + {name: "nativeStruct/pointer", val: nativeStructPtr}, + } + + for _, tc := range inputs { + input := tc.val + b.Run(tc.name, func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() for i := 0; i < b.N; i++ { reg.NativeToValue(input) } @@ -894,6 +1299,170 @@ func BenchmarkNativeToValue(b *testing.B) { } } +func TestRegistryStructTypeDescriptor_FindStructType(t *testing.T) { + reg := newTestStructTypeRegistry(t) + tests := []struct { + name string + wantType string + }{ + {name: "custom.MyStruct", wantType: "type"}, + {name: ".custom.MyStruct", wantType: "type"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + st, found := reg.FindStructType(tc.name) + if !found || st == nil { + t.Fatalf("FindStructType(%q) not found", tc.name) + } + if st.TypeName() != tc.wantType { + t.Errorf("FindStructType(%q).TypeName() = %s, want %s", tc.name, st.TypeName(), tc.wantType) + } + if st.Parameters()[0].TypeName() != "custom.MyStruct" { + t.Errorf("FindStructType(%q) TypeName() = %s, want 'custom.MyStruct'", tc.name, st.Parameters()[0].TypeName()) + } + }) + } +} + +func TestRegistryStructTypeDescriptor_FindStructFieldNames(t *testing.T) { + reg := newTestStructTypeRegistry(t) + names, found := reg.FindStructFieldNames("custom.MyStruct") + if !found { + t.Fatalf("FindStructFieldNames('custom.MyStruct') not found") + } + want := []string{"Bar", "Foo"} + if !reflect.DeepEqual(names, want) { + t.Errorf("FindStructFieldNames() = %v, want %v", names, want) + } +} + +func TestRegistryStructTypeDescriptor_FindStructFieldType(t *testing.T) { + reg := newTestStructTypeRegistry(t) + tests := []struct { + fieldName string + wantType *Type + }{ + {fieldName: "Foo", wantType: StringType}, + {fieldName: "Bar", wantType: IntType}, + } + for _, tc := range tests { + t.Run(tc.fieldName, func(t *testing.T) { + ft, found := reg.FindStructFieldType("custom.MyStruct", tc.fieldName) + if !found || ft == nil { + t.Fatalf("FindStructFieldType(%q) not found", tc.fieldName) + } + if ft.Type != tc.wantType { + t.Errorf("FindStructFieldType(%q).Type = %v, want %v", tc.fieldName, ft.Type, tc.wantType) + } + }) + } +} + +func TestRegistryStructTypeDescriptor_FindIdent(t *testing.T) { + reg := newTestStructTypeRegistry(t) + ident, found := reg.FindIdent("custom.MyStruct") + if !found || ident == nil { + t.Fatalf("FindIdent('custom.MyStruct') not found") + } +} + +func TestRegistryStructTypeDescriptor_NewValue(t *testing.T) { + reg := newTestStructTypeRegistry(t) + val := reg.NewValue("custom.MyStruct", map[string]ref.Val{"Foo": String("hello"), "Bar": Int(42)}) + if IsError(val) { + t.Fatalf("NewValue() failed: %v", val) + } + + t.Run("Foo", func(t *testing.T) { + fooVal := val.(traits.Indexer).Get(String("Foo")) + if fooVal.Equal(String("hello")) != True { + t.Errorf("Get('Foo') = %v, want 'hello'", fooVal) + } + }) + + t.Run("Bar", func(t *testing.T) { + barVal := val.(traits.Indexer).Get(String("Bar")) + if barVal.Equal(Int(42)) != True { + t.Errorf("Get('Bar') = %v, want 42", barVal) + } + }) +} + +func TestRegistryStructTypeDescriptor_NativeToValue(t *testing.T) { + reg := newTestStructTypeRegistry(t) + tests := []struct { + name string + in any + check func(t *testing.T, val ref.Val) + }{ + { + name: "struct instance", + in: dummyNativeStruct{Foo: "hello", Bar: 42}, + check: func(t *testing.T, val ref.Val) { + fooVal := val.(traits.Indexer).Get(String("Foo")) + if fooVal.Equal(String("hello")) != True { + t.Errorf("Get('Foo') = %v, want 'hello'", fooVal) + } + }, + }, + { + name: "pointer to struct instance", + in: &dummyNativeStruct{Foo: "world", Bar: 99}, + check: func(t *testing.T, val ref.Val) { + barVal := val.(traits.Indexer).Get(String("Bar")) + if barVal.Equal(Int(99)) != True { + t.Errorf("Get('Bar') = %v, want 99", barVal) + } + }, + }, + { + name: "slice of struct instances", + in: []dummyNativeStruct{{Foo: "e1"}, {Foo: "e2"}}, + check: func(t *testing.T, val ref.Val) { + lister := val.(traits.Lister) + if lister.Size().Equal(Int(2)) != True { + t.Errorf("Size() = %v, want 2", lister.Size()) + } + e1 := lister.Get(Int(0)).(traits.Indexer).Get(String("Foo")) + if e1.Equal(String("e1")) != True { + t.Errorf("element 0 Foo = %v, want 'e1'", e1) + } + }, + }, + { + name: "map of struct instances", + in: map[string]dummyNativeStruct{"k1": {Foo: "v1"}}, + check: func(t *testing.T, val ref.Val) { + mapper := val.(traits.Mapper) + k1Val := mapper.Get(String("k1")).(traits.Indexer).Get(String("Foo")) + if k1Val.Equal(String("v1")) != True { + t.Errorf("map k1 Foo = %v, want 'v1'", k1Val) + } + }, + }, + { + name: "typed nil pointer to struct instance", + in: (*dummyNativeStruct)(nil), + check: func(t *testing.T, val ref.Val) { + if val != NullValue { + t.Errorf("NativeToValue((*dummyNativeStruct)(nil)) = %v, want NullValue", val) + } + }, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + val := reg.NativeToValue(tc.in) + if IsError(val) { + t.Fatalf("NativeToValue(%s) error: %v", tc.name, val) + } + tc.check(t, val) + }) + } +} + func BenchmarkTypeProviderNewValue(b *testing.B) { reg, err := NewRegistry(&exprpb.ParsedExpr{}) if err != nil { @@ -941,9 +1510,1197 @@ type testString string func newTestRegistry(t *testing.T, opts ...RegistryOption) *Registry { t.Helper() - reg, err := NewProtoRegistry(opts...) + var o []any + for _, opt := range opts { + o = append(o, opt) + } + reg, err := NewRegistry(o...) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + return reg +} + +type dummyNativeStruct struct { + Foo string + Bar int64 +} + +type testStructType struct { + typeName string + reflectType reflect.Type + fields map[string]*FieldType + celType *Type +} + +func (d *testStructType) HasTrait(trait int) bool { + return d.objectType().HasTrait(trait) +} + +func (d *testStructType) TypeName() string { + return d.typeName +} + +func (d *testStructType) objectType() *Type { + if d.celType == nil { + d.celType = NewObjectType(d.typeName) + } + return d.celType +} + +func (d *testStructType) ReflectType() reflect.Type { + return d.reflectType +} + +func (d *testStructType) FieldNames() []string { + names := make([]string, 0, len(d.fields)) + for name := range d.fields { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func (d *testStructType) FindFieldType(fieldName string) (*FieldType, bool) { + ft, found := d.fields[fieldName] + return ft, found +} + +func (d *testStructType) NewValue(adapter Adapter, fields map[string]ref.Val) ref.Val { + if d.reflectType == nil { + return &testStructVal{ + adapter: adapter, + st: d, + value: fields, + } + } + refPtr := reflect.New(d.reflectType) + refVal := refPtr.Elem() + for fieldName, val := range fields { + refField := refVal.FieldByName(fieldName) + if !refField.IsValid() || !refField.CanSet() { + return NewErr("no such field: %s", fieldName) + } + nativeVal, err := val.ConvertToNative(refField.Type()) + if err != nil { + return NewErrFromString(err.Error()) + } + refField.Set(reflect.ValueOf(nativeVal)) + } + var inst any + if d.reflectType.Kind() == reflect.Pointer { + inst = refPtr.Interface() + } else { + inst = refVal.Interface() + } + return d.Adapt(adapter, inst) +} + +func (d *testStructType) Adapt(adapter Adapter, value any) ref.Val { + if value == nil { + return NullValue + } + refVal := reflect.ValueOf(value) + if refVal.Kind() == reflect.Pointer && refVal.IsNil() { + return NullValue + } + return &testStructVal{ + adapter: adapter, + st: d, + value: value, + } +} + +type testStructVal struct { + adapter Adapter + st *testStructType + value any +} + +func (o *testStructVal) ConvertToNative(typeDesc reflect.Type) (any, error) { + if reflect.TypeOf(o.value).AssignableTo(typeDesc) { + return o.value, nil + } + if reflect.TypeOf(o).AssignableTo(typeDesc) { + return o, nil + } + return nil, fmt.Errorf("type conversion error for type to '%v'", typeDesc) +} + +func (o *testStructVal) ConvertToType(typeVal ref.Type) ref.Val { + switch typeVal { + case TypeType: + return NewTypeTypeWithParam(o.Type().(*Type)) + default: + if o.Type().TypeName() == typeVal.TypeName() { + return o + } + } + return NewErr("type conversion error from '%s' to '%s'", o.Type(), typeVal) +} + +func (o *testStructVal) Equal(other ref.Val) ref.Val { + return Bool(reflect.DeepEqual(o.value, other.Value())) +} + +func (o *testStructVal) HasTrait(trait int) bool { + return (traits.FieldTesterType|traits.IndexerType)&trait == trait +} + +func (o *testStructVal) Get(index ref.Val) ref.Val { + fieldName, ok := index.(String) + if !ok { + return MaybeNoSuchOverloadErr(index) + } + ft, found := o.st.FindFieldType(string(fieldName)) + if !found { + return NewErr("no such field: %s", index) + } + if ft.GetFrom == nil { + return NewErr("field '%s' is not readable", index) + } + fv, err := ft.GetFrom(o.value) + if err != nil { + return NewErrFromString(err.Error()) + } + return o.adapter.NativeToValue(fv) +} + +func (o *testStructVal) IsSet(field ref.Val) ref.Val { + fieldName, ok := field.(String) + if !ok { + return MaybeNoSuchOverloadErr(field) + } + ft, found := o.st.FindFieldType(string(fieldName)) + if !found { + return NewErr("no such field: %s", field) + } + if ft.IsSet == nil { + return False + } + return Bool(ft.IsSet(o.value)) +} + +func (o *testStructVal) Type() ref.Type { + return o.st +} + +func (o *testStructVal) Value() any { + return o.value +} + +func newTestStructTypeRegistry(t *testing.T) *Registry { + t.Helper() + desc := &testStructType{ + typeName: "custom.MyStruct", + reflectType: reflect.TypeOf(dummyNativeStruct{}), + fields: map[string]*FieldType{ + "Foo": { + Type: StringType, + GetFrom: func(obj any) (any, error) { + if s, ok := obj.(dummyNativeStruct); ok { + return s.Foo, nil + } + if s, ok := obj.(*dummyNativeStruct); ok { + return s.Foo, nil + } + return nil, fmt.Errorf("unexpected type: %T", obj) + }, + IsSet: func(obj any) bool { return true }, + }, + "Bar": { + Type: IntType, + GetFrom: func(obj any) (any, error) { + if s, ok := obj.(dummyNativeStruct); ok { + return s.Bar, nil + } + if s, ok := obj.(*dummyNativeStruct); ok { + return s.Bar, nil + } + return nil, fmt.Errorf("unexpected type: %T", obj) + }, + IsSet: func(obj any) bool { return true }, + }, + }, + } + reg, err := NewRegistry(Types(desc)) if err != nil { - t.Fatalf("NewProtoRegistry() failed: %v", err) + t.Fatalf("NewRegistry() failed: %v", err) } return reg } + +type testCustomProvider struct { + enumVal ref.Val + identVal ref.Val + structType *Type + fieldNames []string + fieldType *FieldType + newValue ref.Val +} + +func (p *testCustomProvider) EnumValue(enumName string) ref.Val { + if enumName == "custom.Enum.VAL" { + return p.enumVal + } + return NewErr("unknown enum name '%s'", enumName) +} + +func (p *testCustomProvider) FindIdent(identName string) (ref.Val, bool) { + if identName == "customIdent" { + return p.identVal, true + } + return nil, false +} + +func (p *testCustomProvider) FindStructType(structType string) (*Type, bool) { + if structType == "custom.ProviderStruct" { + return p.structType, true + } + return nil, false +} + +func (p *testCustomProvider) FindStructFieldNames(structType string) ([]string, bool) { + if structType == "custom.ProviderStruct" { + return p.fieldNames, true + } + return []string{}, false +} + +func (p *testCustomProvider) FindStructFieldType(structType, fieldName string) (*FieldType, bool) { + if structType == "custom.ProviderStruct" && fieldName == "customField" { + return p.fieldType, true + } + return nil, false +} + +func (p *testCustomProvider) NewValue(structType string, fields map[string]ref.Val) ref.Val { + if structType == "custom.ProviderStruct" { + return p.newValue + } + return NewErr("unknown type '%s'", structType) +} + +func (p *testCustomProvider) FindStructFieldDescription(structType, fieldName string) (string, bool) { + if structType == "custom.ProviderStruct" && fieldName == "customField" { + return "Custom field documentation", true + } + return "", false +} + +func (p *testCustomProvider) FindType(structType string) (*exprpb.Type, bool) { + if structType == "custom.ProviderStruct" { + return &exprpb.Type{ + TypeKind: &exprpb.Type_MessageType{MessageType: structType}, + }, true + } + return nil, false +} + +func (p *testCustomProvider) FindFieldType(structType, fieldName string) (*ref.FieldType, bool) { + if structType == "custom.ProviderStruct" && fieldName == "customField" { + return &ref.FieldType{ + Type: &exprpb.Type{TypeKind: &exprpb.Type_Primitive{Primitive: exprpb.Type_STRING}}, + }, true + } + return nil, false +} + +type customNativeType struct{} + +type testCustomAdapter struct { + adaptedVal ref.Val +} + +func (a *testCustomAdapter) NativeToValue(value any) ref.Val { + if _, ok := value.(customNativeType); ok { + return a.adaptedVal + } + return UnsupportedRefValConversionErr(value) +} + +type testCustomCombined struct { + testCustomProvider + testCustomAdapter +} + +func TestComposeTypes_SameRegistry(t *testing.T) { + reg, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + + p, a, err := ComposeTypes(reg, reg, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + if p != reg || a != reg { + t.Errorf("ComposeTypes() with same registry instance returned different instances: p=%v, a=%v, wanted reg=%v", p, a, reg) + } + + // Verify type was registered directly on reg + _, found := reg.FindStructType("google.expr.proto3.test.TestAllTypes") + if !found { + t.Errorf("FindStructType() did not find registered type on same registry instance") + } +} + +func TestComposeTypes_DifferentRegistries(t *testing.T) { + reg1, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry(reg1) failed: %v", err) + } + reg2, err := NewRegistry(ProtoTypeDefs(&exprpb.ParsedExpr{})) + if err != nil { + t.Fatalf("NewRegistry(reg2) failed: %v", err) + } + + p, a, err := ComposeTypes(reg1, reg2, ProtoTypeDefs(&exprpb.SourceInfo{})) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + if p == reg1 || p == reg2 { + t.Errorf("ComposeTypes() should return a new composed registry, got p=%v", p) + } + if any(p) != any(a) { + t.Errorf("ComposeTypes() returned different provider and adapter: p=%v, a=%v", p, a) + } + + composedReg := p.(*Registry) + + t.Run("registered type on composed registry", func(t *testing.T) { + _, found := composedReg.FindStructType("google.api.expr.v1alpha1.SourceInfo") + if !found { + t.Errorf("FindStructType() failed for newly registered type on composed registry") + } + }) + + t.Run("provider type via proxy", func(t *testing.T) { + _, found := composedReg.FindStructType("google.expr.proto3.test.TestAllTypes") + if !found { + t.Errorf("FindStructType() failed for provider (reg1) type on composed registry") + } + }) + + t.Run("adapter NativeToValue via proxy", func(t *testing.T) { + parsedExpr := &exprpb.ParsedExpr{} + val := composedReg.NativeToValue(parsedExpr) + if IsError(val) { + t.Errorf("NativeToValue() failed for adapter (reg2) type on composed registry: %v", val) + } + }) +} + +func TestComposeTypes_RegistryProviderCustomAdapter(t *testing.T) { + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + customAdapt := &testCustomAdapter{adaptedVal: String("adapted_success")} + + p, a, err := ComposeTypes(reg, customAdapt) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + if any(p) != any(a) { + t.Errorf("ComposeTypes() returned different provider and adapter: p=%v, a=%v", p, a) + } + composedReg := p.(*Registry) + + t.Run("provider delegation", func(t *testing.T) { + _, found := composedReg.FindStructType("google.expr.proto3.test.TestAllTypes") + if !found { + t.Errorf("FindStructType() failed to delegate to provider registry") + } + }) + + t.Run("adapter delegation", func(t *testing.T) { + val := composedReg.NativeToValue(customNativeType{}) + if IsError(val) || val.(String) != "adapted_success" { + t.Errorf("NativeToValue() failed to delegate to custom adapter: got %v, wanted adapted_success", val) + } + }) +} + +func TestComposeTypes_CustomProviderRegistryAdapter(t *testing.T) { + customProv := &testCustomProvider{ + enumVal: Int(42), + identVal: String("ident_ok"), + structType: NewTypeTypeWithParam(NewObjectType("custom.ProviderStruct")), + fieldNames: []string{"customField"}, + fieldType: &FieldType{Type: StringType}, + newValue: String("new_val_ok"), + } + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + + p, a, err := ComposeTypes(customProv, reg) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + if any(p) != any(a) { + t.Errorf("ComposeTypes() returned different provider and adapter: p=%v, a=%v", p, a) + } + composedReg := p.(*Registry) + + t.Run("EnumValue", func(t *testing.T) { + if enumVal := composedReg.EnumValue("custom.Enum.VAL"); IsError(enumVal) || enumVal.(Int) != 42 { + t.Errorf("EnumValue() proxy failed: got %v, wanted 42", enumVal) + } + }) + + t.Run("FindIdent", func(t *testing.T) { + if ident, found := composedReg.FindIdent("customIdent"); !found || ident.(String) != "ident_ok" { + t.Errorf("FindIdent() proxy failed: got %v, found %v", ident, found) + } + }) + + t.Run("FindStructType", func(t *testing.T) { + if st, found := composedReg.FindStructType("custom.ProviderStruct"); !found || st == nil { + t.Errorf("FindStructType() proxy failed: got %v, found %v", st, found) + } + }) + + t.Run("FindStructFieldNames", func(t *testing.T) { + if fields, found := composedReg.FindStructFieldNames("custom.ProviderStruct"); !found || len(fields) != 1 || fields[0] != "customField" { + t.Errorf("FindStructFieldNames() proxy failed: got %v, found %v", fields, found) + } + }) + + t.Run("FindStructFieldType", func(t *testing.T) { + if ft, found := composedReg.FindStructFieldType("custom.ProviderStruct", "customField"); !found || ft.Type != StringType { + t.Errorf("FindStructFieldType() proxy failed: got %v, found %v", ft, found) + } + }) + + t.Run("NewValue", func(t *testing.T) { + if nv := composedReg.NewValue("custom.ProviderStruct", nil); IsError(nv) || nv.(String) != "new_val_ok" { + t.Errorf("NewValue() proxy failed: got %v", nv) + } + }) + + t.Run("NativeToValue adapter proxy", func(t *testing.T) { + msg := &proto3pb.TestAllTypes{} + val := composedReg.NativeToValue(msg) + if IsError(val) { + t.Errorf("NativeToValue() proxy to registry adapter failed: %v", val) + } + }) +} + +func TestComposeTypes_CustomProviderAndAdapter(t *testing.T) { + customProv := &testCustomProvider{ + identVal: String("ident_val"), + } + customAdapt := &testCustomAdapter{ + adaptedVal: String("adapted_val"), + } + + p, a, err := ComposeTypes(customProv, customAdapt) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + if any(p) != any(a) { + t.Errorf("ComposeTypes() returned different provider and adapter: p=%v, a=%v", p, a) + } + composedReg := p.(*Registry) + + t.Run("FindIdent", func(t *testing.T) { + if ident, found := composedReg.FindIdent("customIdent"); !found || ident.(String) != "ident_val" { + t.Errorf("FindIdent() failed on composed registry: got %v", ident) + } + }) + + t.Run("NativeToValue", func(t *testing.T) { + if val := composedReg.NativeToValue(customNativeType{}); IsError(val) || val.(String) != "adapted_val" { + t.Errorf("NativeToValue() failed on composed registry: got %v", val) + } + }) +} + +func TestComposeTypes_SameCustomInstance(t *testing.T) { + customBoth := &testCustomCombined{ + testCustomProvider: testCustomProvider{identVal: String("combined_ident")}, + testCustomAdapter: testCustomAdapter{adaptedVal: String("combined_adapt")}, + } + + p, a, err := ComposeTypes(customBoth, customBoth) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + if any(p) != any(a) { + t.Errorf("ComposeTypes() returned different provider and adapter: p=%v, a=%v", p, a) + } + if _, ok := p.(*Registry); !ok { + t.Fatalf("ComposeTypes() should return a *Registry instance, got %T", p) + } + + composedReg := p.(*Registry) + + t.Run("FindIdent", func(t *testing.T) { + if ident, found := composedReg.FindIdent("customIdent"); !found || ident.(String) != "combined_ident" { + t.Errorf("FindIdent() failed: got %v", ident) + } + }) + + t.Run("NativeToValue", func(t *testing.T) { + if val := composedReg.NativeToValue(customNativeType{}); IsError(val) || val.(String) != "combined_adapt" { + t.Errorf("NativeToValue() failed: got %v", val) + } + }) +} + +func TestComposeTypes_ErrorCases(t *testing.T) { + reg, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + + tests := []struct { + name string + types []any + }{ + { + name: "unsupported type", + types: []any{12345}, + }, + { + name: "conflicting type definitions", + types: []any{ + NewTypeValue("http.Request", traits.ReceiverType), + NewObjectType("http.Request", traits.ReceiverType), + }, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + _, _, err := ComposeTypes(reg, reg, tc.types...) + if err == nil { + t.Errorf("ComposeTypes() expected error, got nil") + } + }) + } +} + +func TestComposeTypes_CopyPreservesProxy(t *testing.T) { + customProv := &testCustomProvider{identVal: String("copied_ident")} + customAdapt := &testCustomAdapter{adaptedVal: String("copied_adapt")} + + p, _, err := ComposeTypes(customProv, customAdapt) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + + reg := p.(*Registry) + copiedReg := reg.Copy() + + t.Run("FindIdent", func(t *testing.T) { + if ident, found := copiedReg.FindIdent("customIdent"); !found || ident.(String) != "copied_ident" { + t.Errorf("Copied registry FindIdent() failed: got %v", ident) + } + }) + + t.Run("NativeToValue", func(t *testing.T) { + if val := copiedReg.NativeToValue(customNativeType{}); IsError(val) || val.(String) != "copied_adapt" { + t.Errorf("Copied registry NativeToValue() failed: got %v", val) + } + }) +} + +func TestRegistryJSONFieldNamesDefault(t *testing.T) { + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + if reg.JSONFieldNames() { + t.Errorf("JSONFieldNames() default expected false, got true") + } +} + +func TestRegistryWithJSONFieldNames(t *testing.T) { + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + err = reg.WithJSONFieldNames(true) + if err != nil { + t.Fatalf("WithJSONFieldNames(true) failed: %v", err) + } + if !reg.JSONFieldNames() { + t.Errorf("JSONFieldNames() after enabling expected true, got false") + } +} + +func TestRegistryWithJSONFieldNamesIdempotent(t *testing.T) { + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + err = reg.WithJSONFieldNames(true) + if err != nil { + t.Fatalf("WithJSONFieldNames(true) failed: %v", err) + } + err = reg.WithJSONFieldNames(true) + if err != nil { + t.Fatalf("WithJSONFieldNames(true) idempotent failed: %v", err) + } + if !reg.JSONFieldNames() { + t.Errorf("JSONFieldNames() expected true, got false") + } +} + +func TestRegistryWithJSONFieldNamesDisabled(t *testing.T) { + reg, err := NewRegistry(ProtoTypeDefs(&proto3pb.TestAllTypes{})) + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + err = reg.WithJSONFieldNames(true) + if err != nil { + t.Fatalf("WithJSONFieldNames(true) failed: %v", err) + } + err = reg.WithJSONFieldNames(false) + if err != nil { + t.Fatalf("WithJSONFieldNames(false) failed: %v", err) + } + if reg.JSONFieldNames() { + t.Errorf("JSONFieldNames() after disabling expected false, got true") + } +} + +func TestRegistry_EnumValueEdgeCases(t *testing.T) { + customProv := &testCustomProvider{ + enumVal: Int(99), + } + reg := newTestRegistry(t, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + err := reg.RegisterDescriptor(proto3pb.GlobalEnum_GOO.Descriptor().ParentFile()) + if err != nil { + t.Fatalf("RegisterDescriptor() failed: %v", err) + } + + p, _, err := ComposeTypes(customProv, reg) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + composedReg := p.(*Registry) + + tests := []struct { + enumName string + target *Registry + wantVal ref.Val + isErr bool + }{ + { + enumName: "google.expr.proto3.test.GlobalEnum.GOO", + target: reg, + wantVal: Int(proto3pb.GlobalEnum_GOO.Number()), + }, + { + enumName: "custom.Enum.VAL", + target: composedReg, + wantVal: Int(99), + }, + { + enumName: "non.existent.Enum", + target: reg, + isErr: true, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.enumName, func(t *testing.T) { + got := tc.target.EnumValue(tc.enumName) + if tc.isErr { + if !IsError(got) { + t.Errorf("EnumValue(%s) expected error, got %v", tc.enumName, got) + } + } else { + if IsError(got) || got.Equal(tc.wantVal) != True { + t.Errorf("EnumValue(%s) got %v, wanted %v", tc.enumName, got, tc.wantVal) + } + } + }) + } +} + +func TestRegistry_FindIdentEdgeCases(t *testing.T) { + customProv := &testCustomProvider{ + identVal: String("ident_found"), + } + reg := newTestRegistry(t, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + err := reg.RegisterDescriptor(proto3pb.GlobalEnum_GOO.Descriptor().ParentFile()) + if err != nil { + t.Fatalf("RegisterDescriptor() failed: %v", err) + } + + p, _, err := ComposeTypes(customProv, reg) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + composedReg := p.(*Registry) + + tests := []struct { + identName string + target *Registry + wantFound bool + }{ + { + identName: "int", + target: composedReg, + wantFound: true, + }, + { + identName: "google.expr.proto3.test.GlobalEnum.GOO", + target: reg, + wantFound: true, + }, + { + identName: "customIdent", + target: composedReg, + wantFound: true, + }, + { + identName: "nonExistentIdent", + target: composedReg, + wantFound: false, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.identName, func(t *testing.T) { + _, found := tc.target.FindIdent(tc.identName) + if found != tc.wantFound { + t.Errorf("FindIdent(%s) found=%v, wanted %v", tc.identName, found, tc.wantFound) + } + }) + } +} + +func TestRegistry_FindTypeEdgeCases(t *testing.T) { + customProv := &testCustomProvider{} + reg := newTestRegistry(t, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + + p, _, err := ComposeTypes(reg, &testCustomAdapter{}) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + composedReg := p.(*Registry) + + p2, _, err := ComposeTypes(customProv, reg) + if err != nil { + t.Fatalf("ComposeTypes(customProv, reg) failed: %v", err) + } + composedReg2 := p2.(*Registry) + + tests := []struct { + typeName string + target *Registry + wantFound bool + }{ + { + typeName: "google.expr.proto3.test.TestAllTypes", + target: composedReg, + wantFound: true, + }, + { + typeName: ".google.expr.proto3.test.TestAllTypes", + target: composedReg, + wantFound: true, + }, + { + typeName: "custom.ProviderStruct", + target: composedReg2, + wantFound: true, + }, + { + typeName: "non.existent.Type", + target: composedReg, + wantFound: false, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.typeName, func(t *testing.T) { + got, found := tc.target.FindType(tc.typeName) + if found != tc.wantFound { + t.Errorf("FindType(%s) found=%v, wanted %v", tc.typeName, found, tc.wantFound) + } + if found && got == nil { + t.Errorf("FindType(%s) returned nil type despite found=true", tc.typeName) + } + }) + } +} + +func TestRegistry_FindFieldTypeEdgeCases(t *testing.T) { + customProv := &testCustomProvider{} + reg := newTestRegistry(t, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + + p, _, err := ComposeTypes(reg, &testCustomAdapter{}) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + composedReg := p.(*Registry) + + p2, _, err := ComposeTypes(customProv, reg) + if err != nil { + t.Fatalf("ComposeTypes(customProv, reg) failed: %v", err) + } + composedReg2 := p2.(*Registry) + + tests := []struct { + structType string + fieldName string + target *Registry + wantFound bool + }{ + { + structType: "google.expr.proto3.test.TestAllTypes", + fieldName: "single_int32", + target: composedReg, + wantFound: true, + }, + { + structType: ".google.expr.proto3.test.TestAllTypes", + fieldName: "single_int32", + target: composedReg, + wantFound: true, + }, + { + structType: "custom.ProviderStruct", + fieldName: "customField", + target: composedReg2, + wantFound: false, + }, + { + structType: "google.expr.proto3.test.TestAllTypes", + fieldName: "non_existent_field", + target: composedReg, + wantFound: false, + }, + { + structType: "non.existent.Type", + fieldName: "some_field", + target: composedReg, + wantFound: false, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.structType+"."+tc.fieldName, func(t *testing.T) { + got, found := tc.target.FindFieldType(tc.structType, tc.fieldName) + if found != tc.wantFound { + t.Errorf("FindFieldType(%s, %s) found=%v, wanted %v", tc.structType, tc.fieldName, found, tc.wantFound) + } + if found && got == nil { + t.Errorf("FindFieldType(%s, %s) returned nil field type despite found=true", tc.structType, tc.fieldName) + } + }) + } +} + +func TestRegistry_FindStructFieldDescriptionEdgeCases(t *testing.T) { + customProv := &testCustomProvider{} + reg := newTestRegistry(t, ProtoTypeDefs(&proto3pb.TestAllTypes{})) + + p, _, err := ComposeTypes(customProv, reg) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + composedReg := p.(*Registry) + + tests := []struct { + structType string + fieldName string + wantFound bool + }{ + { + structType: "custom.ProviderStruct", + fieldName: "customField", + wantFound: true, + }, + { + structType: "google.expr.proto3.test.TestAllTypes", + fieldName: "non_existent_field", + wantFound: false, + }, + { + structType: "non.existent.Type", + fieldName: "some_field", + wantFound: false, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.structType+"."+tc.fieldName, func(t *testing.T) { + _, found := composedReg.FindStructFieldDescription(tc.structType, tc.fieldName) + if found != tc.wantFound { + t.Errorf("FindStructFieldDescription(%s, %s) found=%v, wanted %v", tc.structType, tc.fieldName, found, tc.wantFound) + } + }) + } +} + +type testDummyStructDescriptor struct { + *Type + reflectType reflect.Type + fieldType *FieldType +} + +func (d *testDummyStructDescriptor) ReflectType() reflect.Type { + return d.reflectType +} + +func (d *testDummyStructDescriptor) FieldNames() []string { + return []string{"DummyField"} +} + +func (d *testDummyStructDescriptor) FindFieldType(fieldName string) (*FieldType, bool) { + if fieldName == "DummyField" { + return d.fieldType, true + } + return nil, false +} + +func (d *testDummyStructDescriptor) NewValue(adapter Adapter, fields map[string]ref.Val) ref.Val { + return String("dummy_struct_new") +} + +func (d *testDummyStructDescriptor) Adapt(adapter Adapter, value any) ref.Val { + return String("dummy_struct_adapted") +} + +type testSimpleProvider struct{} + +func (p *testSimpleProvider) EnumValue(enumName string) ref.Val { return NewErr("no enum") } +func (p *testSimpleProvider) FindIdent(identName string) (ref.Val, bool) { return nil, false } +func (p *testSimpleProvider) FindStructType(structType string) (*Type, bool) { + if structType == "simple.Struct" { + return NewTypeTypeWithParam(NewObjectType("simple.Struct")), true + } + return nil, false +} +func (p *testSimpleProvider) FindStructFieldNames(structType string) ([]string, bool) { + return nil, false +} +func (p *testSimpleProvider) FindStructFieldType(structType, fieldName string) (*FieldType, bool) { + if structType == "simple.Struct" && fieldName == "simpleField" { + return &FieldType{Type: StringType}, true + } + return nil, false +} +func (p *testSimpleProvider) NewValue(structType string, fields map[string]ref.Val) ref.Val { + return NewErr("no value") +} + +func TestRegistry_FindStructDescriptorByReflectType(t *testing.T) { + reg, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + + st := &testDummyStructDescriptor{ + Type: NewObjectType("dummy.Struct"), + reflectType: reflect.TypeOf(dummyNativeStruct{}), + fieldType: &FieldType{Type: StringType}, + } + + err = reg.RegisterType(st) + if err != nil { + t.Fatalf("RegisterType() failed: %v", err) + } + + t.Run("Pointer lookup when registered as value", func(t *testing.T) { + val := reg.NativeToValue(&dummyNativeStruct{}) + if IsError(val) || val.(String) != "dummy_struct_adapted" { + t.Errorf("NativeToValue(&dummyNativeStruct{}) = %v, wanted dummy_struct_adapted", val) + } + }) + + t.Run("Value lookup when registered as value", func(t *testing.T) { + val := reg.NativeToValue(dummyNativeStruct{}) + if IsError(val) || val.(String) != "dummy_struct_adapted" { + t.Errorf("NativeToValue(dummyNativeStruct{}) = %v, wanted dummy_struct_adapted", val) + } + }) + + t.Run("Direct findStructDescriptorByReflectType tests", func(t *testing.T) { + pNil, foundNil := reg.findStructDescriptorByReflectType(nil) + if foundNil || pNil != nil { + t.Errorf("findStructDescriptorByReflectType(nil) expected false, got %v", foundNil) + } + + // Manually set pointer-only key in reflectTypes + reg.reflectTypes[reflect.TypeOf(&dummyNativeStruct{})] = st + delete(reg.reflectTypes, reflect.TypeOf(dummyNativeStruct{})) + + // Pass value type (kind != Ptr) -> hits else branch looking up PointerTo + stFound, foundVal := reg.findStructDescriptorByReflectType(reflect.TypeOf(dummyNativeStruct{})) + if !foundVal || stFound != st { + t.Errorf("findStructDescriptorByReflectType(value) expected st, got %v", stFound) + } + + // Manually set value-only key in reflectTypes + reg.reflectTypes[reflect.TypeOf(dummyNativeStruct{})] = st + delete(reg.reflectTypes, reflect.TypeOf(&dummyNativeStruct{})) + + // Pass pointer type (kind == Ptr) -> hits Ptr branch looking up Elem + stFound, foundPtr := reg.findStructDescriptorByReflectType(reflect.TypeOf(&dummyNativeStruct{})) + if !foundPtr || stFound != st { + t.Errorf("findStructDescriptorByReflectType(pointer) expected st, got %v", stFound) + } + }) + + t.Run("FindFieldType on registered StructTypeDescriptor", func(t *testing.T) { + ft, found := reg.FindFieldType("dummy.Struct", "DummyField") + if !found || ft == nil { + t.Fatalf("FindFieldType(dummy.Struct, DummyField) failed") + } + }) + + t.Run("Invalid field type conversion error", func(t *testing.T) { + stInvalid := &testDummyStructDescriptor{ + Type: NewObjectType("invalid.Struct"), + reflectType: reflect.TypeOf(customNativeType{}), + fieldType: &FieldType{Type: &Type{}}, + } + err := reg.RegisterType(stInvalid) + if err != nil { + t.Fatalf("RegisterType(stInvalid) failed: %v", err) + } + _, found := reg.FindFieldType("invalid.Struct", "DummyField") + if found { + t.Errorf("FindFieldType(invalid.Struct, DummyField) expected false due to TypeToExprType error, got true") + } + }) +} + +func TestRegistry_FindFieldType_SimpleProviderFallback(t *testing.T) { + baseReg, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + + simpleProv := &testSimpleProvider{} + p, _, err := ComposeTypes(simpleProv, baseReg) + if err != nil { + t.Fatalf("ComposeTypes() failed: %v", err) + } + composedReg := p.(*Registry) + + t.Run("fallback to FindStructFieldType", func(t *testing.T) { + ft, found := composedReg.FindFieldType("simple.Struct", "simpleField") + if !found || ft == nil { + t.Fatalf("FindFieldType(simple.Struct, simpleField) failed on fallback provider") + } + }) + + t.Run("FindStructFieldDescription without interface", func(t *testing.T) { + _, found := composedReg.FindStructFieldDescription("simple.Struct", "simpleField") + if found { + t.Errorf("FindStructFieldDescription() on simpleProv expected false, got true") + } + }) +} + +func TestRegistry_NewRegistry_OptionErrors(t *testing.T) { + optErr := RegistryOption(func(r *Registry) (*Registry, error) { + return nil, fmt.Errorf("registry option error") + }) + + t.Run("NewProtoRegistry", func(t *testing.T) { + _, err := NewProtoRegistry(optErr) + if err == nil || err.Error() != "registry option error" { + t.Errorf("NewProtoRegistry() expected 'registry option error', got %v", err) + } + }) + + t.Run("NewRegistry", func(t *testing.T) { + _, err := NewRegistry(optErr) + if err == nil || err.Error() != "registry option error" { + t.Errorf("NewRegistry() expected 'registry option error', got %v", err) + } + }) +} + +func TestRegistry_RegisterTypeEdgeCases(t *testing.T) { + tests := []struct { + name string + targetType *Type + }{ + { + name: "conflicting traits", + targetType: NewTypeValue("bool", traits.ContainerType), + }, + { + name: "conflicting type definition", + targetType: NewObjectType("bool"), + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + reg, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() failed: %v", err) + } + err = reg.RegisterType(tc.targetType) + if err == nil { + t.Errorf("RegisterType() expected error, got nil") + } + }) + } +} + +type sampleTaggedStruct struct { + Greeting string `cel:"hello_str"` + Count int `cel:"count_int"` +} + +func TestRegistry_NativeReflectTypes(t *testing.T) { + reg, err := NewRegistry( + ParseStructTags(true), + reflect.TypeFor[sampleTaggedStruct](), + ) + if err != nil { + t.Fatalf("NewRegistry(reflect.Type) failed: %v", err) + } + + t.Run("FindStructType", func(t *testing.T) { + st, found := reg.FindStructType("types.sampleTaggedStruct") + if !found || st == nil { + t.Fatalf("FindStructType(types.sampleTaggedStruct) not found") + } + }) + + t.Run("FindStructFieldType with tag", func(t *testing.T) { + ft, found := reg.FindStructFieldType("types.sampleTaggedStruct", "hello_str") + if !found || ft == nil { + t.Fatalf("FindStructFieldType(hello_str) not found") + } + if ft.Type != StringType { + t.Errorf("FindStructFieldType(hello_str) got %v, want StringType", ft.Type) + } + }) + + t.Run("NativeToValue", func(t *testing.T) { + inst := sampleTaggedStruct{Greeting: "world", Count: 42} + val := reg.NativeToValue(&inst) + if IsError(val) { + t.Fatalf("NativeToValue() failed: %v", val) + } + gotGreeting := val.(traits.Indexer).Get(String("hello_str")) + if gotGreeting.Equal(String("world")) != True { + t.Errorf("Get(hello_str) = %v, want 'world'", gotGreeting) + } + }) +} diff --git a/common/types/regex.go b/common/types/regex.go new file mode 100644 index 000000000..14173eb52 --- /dev/null +++ b/common/types/regex.go @@ -0,0 +1,49 @@ +// 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 +// +// 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 types + +import ( + "fmt" + "regexp" + "regexp/syntax" +) + +// RegexProgramSize calculates the instruction count (program plan size) of a regex pattern. +func RegexProgramSize(pattern string) (int, error) { + re, err := syntax.Parse(pattern, syntax.Perl) + if err != nil { + return 0, err + } + prog, err := syntax.Compile(re) + if err != nil { + return 0, err + } + return len(prog.Inst), nil +} + +// CompileRegexWithLimit compiles a regex pattern and verifies that its program plan size does not exceed limit. +// A limit <= 0 means unbounded. +func CompileRegexWithLimit(pattern string, limit int) (*regexp.Regexp, error) { + if limit > 0 { + sz, err := RegexProgramSize(pattern) + if err != nil { + return nil, err + } + if sz > limit { + return nil, fmt.Errorf("regex program size %d exceeds limit of %d", sz, limit) + } + } + return regexp.Compile(pattern) +} diff --git a/common/types/regex_test.go b/common/types/regex_test.go new file mode 100644 index 000000000..033a43979 --- /dev/null +++ b/common/types/regex_test.go @@ -0,0 +1,76 @@ +// 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 +// +// 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 types + +import ( + "testing" +) + +func TestRegexProgramSize(t *testing.T) { + tests := []struct { + pattern string + minSize int + hasError bool + }{ + {pattern: "a", minSize: 1}, + {pattern: "el*", minSize: 3}, + {pattern: "(a|b)*[0-9]+", minSize: 5}, + {pattern: "(", hasError: true}, + } + + for _, tc := range tests { + sz, err := RegexProgramSize(tc.pattern) + if tc.hasError { + if err == nil { + t.Errorf("RegexProgramSize(%q) expected error, got nil", tc.pattern) + } + continue + } + if err != nil { + t.Errorf("RegexProgramSize(%q) unexpected error: %v", tc.pattern, err) + continue + } + if sz < tc.minSize { + t.Errorf("RegexProgramSize(%q) = %d, expected >= %d", tc.pattern, sz, tc.minSize) + } + } +} + +func TestCompileRegexWithLimit(t *testing.T) { + tests := []struct { + pattern string + limit int + hasError bool + }{ + {pattern: "el*", limit: 10}, + {pattern: "el*", limit: 0}, + {pattern: "el*", limit: -1}, + {pattern: "(a|b)*[0-9]+", limit: 5, hasError: true}, + {pattern: "(", limit: 10, hasError: true}, + } + + for _, tc := range tests { + _, err := CompileRegexWithLimit(tc.pattern, tc.limit) + if tc.hasError { + if err == nil { + t.Errorf("CompileRegexWithLimit(%q, %d) expected error, got nil", tc.pattern, tc.limit) + } + } else { + if err != nil { + t.Errorf("CompileRegexWithLimit(%q, %d) unexpected error: %v", tc.pattern, tc.limit, err) + } + } + } +} diff --git a/common/types/size_calc.go b/common/types/size_calc.go new file mode 100644 index 000000000..edc6c1ae7 --- /dev/null +++ b/common/types/size_calc.go @@ -0,0 +1,410 @@ +// 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 +// +// 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 types + +import ( + "math" + "reflect" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/authzed/cel-go/common/types/ref" + "github.com/authzed/cel-go/common/types/traits" +) + +const ( + defaultSizeCalculatorMaxDepth = 5 + defaultSizeCalculatorMaxTraversal = 10000 + defaultSizeCalculatorStringUnitLength = 10 +) + +// SizeCalculatorOption configures a SizeCalculator instance. +type SizeCalculatorOption func(*SizeCalculator) + +// SizeCalculatorMaxDepth sets the maximum object depth limit before saturating to math.MaxUint32. +func SizeCalculatorMaxDepth(depth int) SizeCalculatorOption { + return func(s *SizeCalculator) { + s.maxDepth = depth + } +} + +// SizeCalculatorMaxTraversal sets the maximum object traversal limit before saturating to math.MaxUint32. +func SizeCalculatorMaxTraversal(traversal int) SizeCalculatorOption { + return func(s *SizeCalculator) { + s.maxTraversal = traversal + } +} + +// SizeCalculatorStringUnitLength sets the number of string or bytes value bytes which count as +// a single element (default 10). Values less than 1 are treated as 1, meaning each byte counts +// as a whole element. +// +// String sizes are measured in bytes rather than characters so that sizing large values is +// O(1) rather than a full UTF-8 scan per observation; byte length is never smaller than the +// character count, so byte-based sizing is conservative for limit enforcement. +func SizeCalculatorStringUnitLength(length int) SizeCalculatorOption { + return func(s *SizeCalculator) { + if length < 1 { + length = 1 + } + s.stringUnitLength = length + } +} + +// SizeCalculator calculates the recursive element size of values. +// +// Aggregate values may memoize their computed size on first calculation as an optimization +// for repeated sizing of shared structures. The memoized size reflects the configuration of +// the calculator which first sized the value; hosts requiring differently configured +// calculators, e.g. distinct depth or traversal limits, should not share value instances +// across them. Memoized totals are also a snapshot of the value's contents at first sizing: +// hosts which mutate data underlying a sized aggregate, e.g. a proto message held as a list +// element, will observe the total computed before the mutation. Sizes computed from +// calculations aborted at the depth or traversal limits are never memoized. +type SizeCalculator struct { + version int + maxDepth int + maxTraversal int + stringUnitLength int +} + +// NewSizeCalculator returns a new SizeCalculator configured with optional SizeCalculatorOption settings. +func NewSizeCalculator(opts ...SizeCalculatorOption) *SizeCalculator { + s := &SizeCalculator{ + version: 0, + maxDepth: defaultSizeCalculatorMaxDepth, + maxTraversal: defaultSizeCalculatorMaxTraversal, + stringUnitLength: defaultSizeCalculatorStringUnitLength, + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Version returns the calculation version. +func (s *SizeCalculator) Version() int { + return s.version +} + +type sizeContext struct { + calc *SizeCalculator + depth int + traversalCount *int + limitExceeded *bool +} + +func (c sizeContext) childContext() sizeContext { + c.depth++ + return c +} + +func (c sizeContext) visitNode() bool { + *c.traversalCount++ + if *c.traversalCount > c.calc.maxTraversal || c.depth > c.calc.maxDepth { + *c.limitExceeded = true + return false + } + return true +} + +// aggregateSizeStatus exposes whether the in-flight size computation has exceeded the +// calculator's depth or traversal limits. +type aggregateSizeStatus interface { + aggregateSizeLimitExceeded() bool +} + +// aggregateSizeLimitExceeded implements the aggregateSizeStatus interface method. +func (c sizeContext) aggregateSizeLimitExceeded() bool { + return *c.limitExceeded +} + +// cacheableAggregateSize reports whether a size computed with the given sizer is safe to +// memoize on the value. Only totals from computations which verifiably stayed within the +// calculator's depth and traversal limits are stable properties of the value; totals from +// aborted computations depend on where in the traversal the value was encountered and would +// poison the memoized size. +func cacheableAggregateSize(sizer AggregateSizer) bool { + status, ok := sizer.(aggregateSizeStatus) + return ok && !status.aggregateSizeLimitExceeded() +} + +// AggregateSizeEstimate captures the outcome of an aggregate size computation. +// +// The Size saturates at math.MaxUint32 when the accumulated element count overflows uint32. +// LimitExceeded reports the computation was aborted because the value was too expensive to +// traverse (too deep, or too many nodes visited); in that case Size is also math.MaxUint32, +// but the value's true size may be smaller — the two conditions are distinguishable by the flag. +type AggregateSizeEstimate struct { + Size uint32 + LimitExceeded bool +} + +// AggregateSize returns the size of the input value, if known. +// Otherwise, a unit size of 1 is returned. +// +// When the calculator's depth or traversal limits are exceeded, the size saturates to +// math.MaxUint32. Use EstimateAggregateSize to distinguish limit-exceeded results from +// genuine uint32 saturation. +func (s *SizeCalculator) AggregateSize(val any) uint32 { + return s.EstimateAggregateSize(val).Size +} + +// EstimateAggregateSize returns the aggregate size of the input value along with an indication +// of whether the computation was aborted due to the calculator's depth or traversal limits. +func (s *SizeCalculator) EstimateAggregateSize(val any) AggregateSizeEstimate { + traversals := 0 + exceeded := false + ctx := sizeContext{ + calc: s, + depth: 1, + traversalCount: &traversals, + limitExceeded: &exceeded, + } + size := ctx.AggregateSize(val) + return AggregateSizeEstimate{Size: size, LimitExceeded: exceeded} +} + +// stringSize converts a byte length to an element count where stringUnitLength bytes count +// as a single element, rounding up with a minimum size of 1. +func (s *SizeCalculator) stringSize(length int) uint32 { + if length <= 0 { + return 1 + } + return safeUint32FromInt((length + s.stringUnitLength - 1) / s.stringUnitLength) +} + +// AggregateSize implements the ref.Val interface and allows for the generation of nested +// child context values which are necessary for correct traversal count tracking. +func (c sizeContext) AggregateSize(val any) uint32 { + if !c.visitNode() { + return math.MaxUint32 + } + switch v := val.(type) { + case String: + return c.calc.stringSize(len(v)) + case Bytes: + return c.calc.stringSize(len(v)) + case AggregateSizeVisitor: + return v.AggregateSize(c.childContext()) + case traits.Foldable: + f := foldableAggregateSizer{sizer: c.childContext(), total: 1} + v.Fold(&f) + return f.total + case traits.Mapper: + total := uint32(1) + it := v.Iterator() + childCtx := c.childContext() + for it.HasNext() == True { + key := it.Next() + val, _ := v.Find(key) + total = safeAddUint32(total, childCtx.AggregateSize(key)) + total = safeAddUint32(total, childCtx.AggregateSize(val)) + } + return total + case traits.Lister: + total := uint32(1) + it := v.Iterator() + childCtx := c.childContext() + for it.HasNext() == True { + total = safeAddUint32(total, childCtx.AggregateSize(it.Next())) + } + return total + case traits.Sizer: + return safeUint32FromBoxedInt(v.Size().(Int)) + case Bool, Int, Uint, Double, Duration, Timestamp, Null, *Type, *Err, *Unknown: + return 1 + case ref.Val: + return c.AggregateSize(v.Value()) + case protoreflect.Value: + return c.AggregateSize(v.Interface()) + case protoreflect.MapKey: + return c.AggregateSize(v.Value().Interface()) + case protoreflect.Message: + return getProtoMessageAggregateSize(c, v) + case protoreflect.List: + return getProtoListAggregateSize(c, v) + case protoreflect.Map: + return getProtoMapAggregateSize(c, v) + case proto.Message: + if v == nil { + return 0 + } + return getProtoMessageAggregateSize(c, v.ProtoReflect()) + case reflect.Value: + return getReflectValueAggregateSize(c, v) + case string: + return c.calc.stringSize(len(v)) + case []byte: + return c.calc.stringSize(len(v)) + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64, bool, time.Time, time.Duration, nil: + return 1 + default: + return getReflectValueAggregateSize(c, reflect.ValueOf(val)) + } +} + +func getProtoFieldAggregateSize(c sizeContext, fd protoreflect.FieldDescriptor, v protoreflect.Value) uint32 { + if !c.visitNode() { + return math.MaxUint32 + } + childCtx := c.childContext() + if fd.IsMap() { + return getProtoMapAggregateSize(childCtx, v.Map()) + } + if fd.IsList() { + return getProtoListAggregateSize(childCtx, v.List()) + } + return childCtx.AggregateSize(v.Interface()) +} + +func getProtoMessageAggregateSize(c sizeContext, m protoreflect.Message) uint32 { + if !m.IsValid() { + return 0 + } + if !c.visitNode() { + return math.MaxUint32 + } + childCtx := c.childContext() + total := uint32(1) + m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + total = safeAddUint32(total, getProtoFieldAggregateSize(childCtx, fd, v)) + return true + }) + return total +} + +func getProtoListAggregateSize(c sizeContext, l protoreflect.List) uint32 { + if !l.IsValid() { + return 0 + } + if !c.visitNode() { + return math.MaxUint32 + } + childCtx := c.childContext() + total := uint32(1) + for i := range l.Len() { + total = safeAddUint32(total, childCtx.AggregateSize(l.Get(i).Interface())) + } + return total +} + +func getProtoMapAggregateSize(c sizeContext, m protoreflect.Map) uint32 { + if !m.IsValid() { + return 0 + } + if !c.visitNode() { + return math.MaxUint32 + } + childCtx := c.childContext() + total := uint32(1) + m.Range(func(k protoreflect.MapKey, v protoreflect.Value) bool { + total = safeAddUint32(total, childCtx.AggregateSize(k.Value().Interface())) + total = safeAddUint32(total, childCtx.AggregateSize(v.Interface())) + return true + }) + return total +} + +func getReflectValueAggregateSize(c sizeContext, fieldVal reflect.Value) uint32 { + if !fieldVal.IsValid() { + return 0 + } + if !c.visitNode() { + return math.MaxUint32 + } + childCtx := c.childContext() + switch fieldVal.Kind() { + case reflect.String: + return c.calc.stringSize(fieldVal.Len()) + case reflect.Slice, reflect.Array: + elemType := fieldVal.Type().Elem() + if elemType.Kind() == reflect.Uint8 { + return c.calc.stringSize(fieldVal.Len()) + } + total := safeAddUint32(1, safeUint32FromInt(fieldVal.Len())) + switch elemType.Kind() { + case reflect.String: + total = 1 + for i := 0; i < fieldVal.Len(); i++ { + total = safeAddUint32(total, childCtx.AggregateSize(fieldVal.Index(i).String())) + } + case reflect.Struct, reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map, reflect.Interface: + total = 1 + for i := 0; i < fieldVal.Len(); i++ { + total = safeAddUint32(total, getReflectValueAggregateSize(childCtx, fieldVal.Index(i))) + } + } + return total + case reflect.Map: + total := uint32(1) + iter := fieldVal.MapRange() + for iter.Next() { + total = safeAddUint32(total, getReflectValueAggregateSize(childCtx, iter.Key())) + total = safeAddUint32(total, getReflectValueAggregateSize(childCtx, iter.Value())) + } + return total + case reflect.Pointer, reflect.Interface: + if fieldVal.IsNil() { + return 0 + } + if sz, ok := checkCustomSizer(childCtx, fieldVal); ok { + return sz + } + return getReflectValueAggregateSize(c, fieldVal.Elem()) + case reflect.Struct: + if fieldVal.Type() == timestampType || fieldVal.Type() == durationType { + return 1 + } + if sz, ok := checkCustomSizer(childCtx, fieldVal); ok { + return sz + } + total := uint32(1) + t := fieldVal.Type() + numFields := fieldVal.NumField() + for i := range numFields { + if !t.Field(i).IsExported() { + continue + } + fVal := fieldVal.Field(i) + if !fVal.IsValid() || fVal.IsZero() { + continue + } + total = safeAddUint32(total, getReflectValueAggregateSize(childCtx, fVal)) + } + return total + default: + return 1 + } +} + +func checkCustomSizer(c sizeContext, fieldVal reflect.Value) (uint32, bool) { + if !fieldVal.CanInterface() { + return 0, false + } + + switch sizer := fieldVal.Interface().(type) { + case AggregateSizeVisitor: + return sizer.AggregateSize(c), true + case traits.Sizer: + return safeUint32FromBoxedInt(sizer.Size().(Int)), true + default: + return 0, false + } +} diff --git a/common/types/size_calc_test.go b/common/types/size_calc_test.go new file mode 100644 index 000000000..f2077664d --- /dev/null +++ b/common/types/size_calc_test.go @@ -0,0 +1,964 @@ +// 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 +// +// 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 types + +import ( + "fmt" + "math" + "reflect" + "sync" + "testing" + "time" + + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/authzed/cel-go/common/types/ref" + "github.com/authzed/cel-go/common/types/traits" + + proto3pb "github.com/authzed/cel-go/test/proto3pb" +) + +func TestCalculateSize(t *testing.T) { + adapter := DefaultTypeAdapter + + tests := []struct { + name string + val any + want uint32 + }{ + { + name: "aggregate_sizer_list", + val: NewRefValList(adapter, []ref.Val{Int(1), Int(2)}), + want: 3, + }, + { + name: "sizer_string", + val: String("hello"), + want: 1, // 5 bytes round up to a single 10-byte element unit + }, + { + name: "sizer_bytes", + val: Bytes("world"), + want: 1, + }, + { + name: "err_val", + val: NewErr("test error"), + want: 1, + }, + { + name: "unknown_val", + val: &Unknown{}, + want: 1, + }, + { + name: "type_val", + val: IntType, + want: 1, + }, + { + name: "null_val", + val: NullValue, + want: 1, + }, + { + name: "scalar_ref_val_int", + val: Int(42), + want: 1, + }, + { + name: "scalar_ref_val_double", + val: Double(1.5), + want: 1, + }, + { + name: "scalar_ref_val_bool", + val: True, + want: 1, + }, + { + name: "scalar_ref_val_timestamp", + val: Timestamp{Time: time.Unix(100, 0)}, + want: 1, + }, + { + name: "scalar_ref_val_duration", + val: Duration{Duration: time.Second}, + want: 1, + }, + { + name: "proto_value_string", + val: protoreflect.ValueOfString("hello"), + want: 1, + }, + { + name: "proto_value_bytes", + val: protoreflect.ValueOfBytes([]byte("world")), + want: 1, + }, + { + name: "proto_value_int", + val: protoreflect.ValueOfInt32(42), + want: 1, + }, + { + name: "proto_map_key", + val: protoreflect.MapKey(protoreflect.ValueOfString("key")), + want: 1, + }, + { + name: "proto_message", + val: &proto3pb.TestAllTypes{SingleString: "hello"}, + want: 2, // 1 (root) + 1 (string unit) = 2 + }, + { + name: "proto_message_with_list_and_map", + val: &proto3pb.TestAllTypes{ + RepeatedString: []string{"a", "b"}, + MapStringString: map[string]string{"k": "v"}, + }, + want: 7, + }, + { + name: "protoreflect_message", + val: (&proto3pb.TestAllTypes{SingleInt64: 10}).ProtoReflect(), + want: 2, // 1 (root) + 1 (int64) = 2 + }, + { + name: "protoreflect_list", + val: (&proto3pb.TestAllTypes{RepeatedString: []string{"a", "b"}}).ProtoReflect().Get((&proto3pb.TestAllTypes{}).ProtoReflect().Descriptor().Fields().ByName("repeated_string")).List(), + want: 3, // 1 (container) + 1("a") + 1("b") = 3 + }, + { + name: "protoreflect_map", + val: (&proto3pb.TestAllTypes{MapStringString: map[string]string{"k": "v"}}).ProtoReflect().Get((&proto3pb.TestAllTypes{}).ProtoReflect().Descriptor().Fields().ByName("map_string_string")).Map(), + want: 3, // 1 (container) + 1("k") + 1("v") = 3 + }, + { + name: "nil_proto_message", + val: (*proto3pb.TestAllTypes)(nil), + want: 0, + }, + { + name: "reflect_value", + val: reflect.ValueOf("reflected"), + want: 1, + }, + { + name: "native_string", + val: "hello", + want: 1, + }, + { + name: "native_bytes", + val: []byte("world"), + want: 1, + }, + { + name: "native_int", + val: 42, + want: 1, + }, + { + name: "native_float", + val: 3.14, + want: 1, + }, + { + name: "native_bool", + val: true, + want: 1, + }, + { + name: "native_time", + val: time.Now(), + want: 1, + }, + { + name: "native_duration", + val: time.Hour, + want: 1, + }, + { + name: "native_nil", + val: nil, + want: 1, + }, + { + name: "custom_struct", + val: struct{ Name string }{"cel"}, + want: 2, // 1 (root) + 1 ("cel") = 2 + }, + { + name: "custom_lister", + val: proxyLegacyList{proxy: NewRefValList(DefaultTypeAdapter, []ref.Val{String("a"), String("b")})}, + want: 3, // 1 (container) + 1 ("a") + 1 ("b") = 3 + }, + { + name: "custom_mapper", + val: interopFoldableMap{Mapper: NewStringStringMap(DefaultTypeAdapter, map[string]string{"key": "val"})}, + want: 3, // 1 (container) + 1 ("key") + 1 ("val") = 3 + }, + { + name: "custom_pure_mapper", + val: customPureMapper{Mapper: NewStringStringMap(DefaultTypeAdapter, map[string]string{"key": "val"})}, + want: 3, // 1 (container) + 1 ("key") + 1 ("val") = 3 + }, + { + name: "custom_sizer_struct_field", + val: struct{ Sizer traits.Sizer }{Sizer: customSizerVal(42)}, + want: 43, // 1 (struct container) + 42 (custom sizer) = 43 + }, + { + name: "custom_visitor_struct_field", + val: struct{ Visitor customVisitorVal }{Visitor: customVisitorVal{Val: 1}}, + want: 101, // 1 (struct container) + 100 (custom visitor) = 101 + }, + { + name: "custom_sizer_pointer", + val: newCustomSizerPtr(42), + want: 42, + }, + { + name: "custom_visitor_pointer", + val: &customVisitorVal{Val: 1}, + want: 100, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + calculator := NewSizeCalculator() + if got := calculator.AggregateSize(tc.val); got != tc.want { + t.Errorf("AggregateSize(%v) got %d, want %d", tc.val, got, tc.want) + } + }) + } +} + +type customPureMapper struct { + traits.Mapper +} + +type customSizerVal int + +func (c customSizerVal) Size() ref.Val { + return Int(c) +} + +type customSizerPtr struct { + val int +} + +func (c *customSizerPtr) Size() ref.Val { + return Int(c.val) +} + +func newCustomSizerPtr(v int) *customSizerPtr { + return &customSizerPtr{val: v} +} + +type customVisitorVal struct { + Val int +} + +func (c customVisitorVal) AggregateSize(sizer AggregateSizer) uint32 { + return 100 +} + +func TestNativeObjCalculateSizeNil(t *testing.T) { + nilNative := &nativeObj{} + if got := nilNative.AggregateSize(NewSizeCalculator()); got != 0 { + t.Errorf("nil nativeObj.AggregateSize() got %d, want 0", got) + } +} + +func TestSizeCalculatorOptions(t *testing.T) { + adapter := DefaultTypeAdapter + + var makeNestedList func(depth int) ref.Val + makeNestedList = func(depth int) ref.Val { + if depth <= 1 { + return NewRefValList(adapter, []ref.Val{Int(1)}) + } + return NewRefValList(adapter, []ref.Val{makeNestedList(depth - 1)}) + } + + t.Run("maxDepth default limit 5", func(t *testing.T) { + calc := NewSizeCalculator() + list5 := makeNestedList(4) + if got := calc.AggregateSize(list5); got == math.MaxUint32 { + t.Errorf("AggregateSize for depth 5 got MaxUint32, want calculated size") + } + + list6 := makeNestedList(5) + if got := calc.AggregateSize(list6); got != math.MaxUint32 { + t.Errorf("AggregateSize for depth 6 got %d, want MaxUint32", got) + } + }) + + t.Run("maxDepth custom option", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(2)) + list2 := makeNestedList(1) + if got := calc.AggregateSize(list2); got == math.MaxUint32 { + t.Errorf("AggregateSize for depth 2 got MaxUint32, want calculated size") + } + + list3 := makeNestedList(2) + if got := calc.AggregateSize(list3); got != math.MaxUint32 { + t.Errorf("AggregateSize for depth 3 got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal default limit 10000", func(t *testing.T) { + calc := NewSizeCalculator() + smallElems := make([]ref.Val, 100) + for i := 0; i < 100; i++ { + smallElems[i] = Int(i) + } + smallList := NewRefValList(adapter, smallElems) + if got := calc.AggregateSize(smallList); got == math.MaxUint32 { + t.Errorf("AggregateSize for 100 elements got MaxUint32, want calculated size") + } + + largeElems := make([]ref.Val, 10001) + for i := 0; i < 10001; i++ { + largeElems[i] = Int(i) + } + largeList := NewRefValList(adapter, largeElems) + if got := calc.AggregateSize(largeList); got != math.MaxUint32 { + t.Errorf("AggregateSize for 10001 elements got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal custom option", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(5)) + list4 := NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3), Int(4)}) + if got := calc.AggregateSize(list4); got == math.MaxUint32 { + t.Errorf("AggregateSize for 5 nodes got MaxUint32, want calculated size") + } + + list5 := NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3), Int(4), Int(5)}) + if got := calc.AggregateSize(list5); got != math.MaxUint32 { + t.Errorf("AggregateSize for 6 nodes got %d, want MaxUint32", got) + } + }) + + t.Run("proto depth limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(2)) + msg := &proto3pb.TestAllTypes{ + RepeatedNestedMessage: []*proto3pb.TestAllTypes_NestedMessage{ + {Bb: 42}, + }, + } + if got := calc.AggregateSize(msg); got != math.MaxUint32 { + t.Errorf("AggregateSize for proto nested msg got %d, want MaxUint32", got) + } + }) + + t.Run("cel map depth limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(2)) + nestedMap := NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("k"): NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("subk"): String("subv"), + }), + }) + if got := calc.AggregateSize(nestedMap); got != math.MaxUint32 { + t.Errorf("AggregateSize for nested map got %d, want MaxUint32", got) + } + }) + + t.Run("native struct depth limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(2)) + type Level3 struct{ Val string } + type Level2 struct{ L3 Level3 } + type Level1 struct{ L2 Level2 } + + obj := Level1{L2: Level2{L3: Level3{Val: "deep"}}} + if got := calc.AggregateSize(obj); got != math.MaxUint32 { + t.Errorf("AggregateSize for native struct depth > 2 got %d, want MaxUint32", got) + } + }) + + t.Run("native map depth limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(2)) + m := map[string]map[string]string{ + "outer": {"inner": "val"}, + } + if got := calc.AggregateSize(m); got != math.MaxUint32 { + t.Errorf("AggregateSize for native nested map depth > 2 got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal map limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(3)) + m := NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("k1"): String("v1"), + String("k2"): String("v2"), + }) + if got := calc.AggregateSize(m); got != math.MaxUint32 { + t.Errorf("AggregateSize for map traversal > 3 got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal proto limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(2)) + msg := &proto3pb.TestAllTypes{ + SingleString: "hello", + SingleInt64: 42, + } + if got := calc.AggregateSize(msg); got != math.MaxUint32 { + t.Errorf("AggregateSize for proto traversal > 2 got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal native struct limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(2)) + s := struct{ A, B, C int }{A: 1, B: 2, C: 3} + if got := calc.AggregateSize(s); got != math.MaxUint32 { + t.Errorf("AggregateSize for native struct traversal > 2 got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal native map limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(3)) + m := map[string]int{"a": 1, "b": 2} + if got := calc.AggregateSize(m); got != math.MaxUint32 { + t.Errorf("AggregateSize for native map traversal > 3 got %d, want MaxUint32", got) + } + }) + + t.Run("maxTraversal native slice limit", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(3)) + slice := []string{"a", "b", "c"} + if got := calc.AggregateSize(slice); got != math.MaxUint32 { + t.Errorf("AggregateSize for native slice traversal > 3 got %d, want MaxUint32", got) + } + }) + + t.Run("zero depth limit saturation", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(0)) + if got := calc.AggregateSize(Int(42)); got != math.MaxUint32 { + t.Errorf("AggregateSize with depth 0 got %d, want MaxUint32", got) + } + }) + + t.Run("zero traversal limit saturation", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(0)) + if got := calc.AggregateSize(Int(42)); got != math.MaxUint32 { + t.Errorf("AggregateSize with traversal 0 got %d, want MaxUint32", got) + } + }) +} + +type nestedNative struct { + NestedList []string + NestedMap map[string]int +} + +type rootNative struct { + Name string + Count int + Children []nestedNative +} + +func BenchmarkCalculateSizeAmortized(b *testing.B) { + adapter := DefaultTypeAdapter + + flatList := NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3), Int(4), Int(5), Int(6), Int(7), Int(8)}) + nestedList := NewRefValList(adapter, []ref.Val{ + String("hello"), + NewRefValList(adapter, []ref.Val{String("nested1"), String("nested2")}), + NewRefValMap(adapter, map[ref.Val]ref.Val{String("k1"): String("v1")}), + }) + flatMap := NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("k1"): Int(1), + String("k2"): Int(2), + String("k3"): Int(3), + }) + protoMsg := &proto3pb.TestAllTypes{ + SingleString: "hello world", + SingleInt64: 42, + RepeatedString: []string{"first", "second", "third"}, + MapStringString: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + } + nativeData := &rootNative{ + Name: "parent", + Count: 100, + Children: []nestedNative{ + {NestedList: []string{"a", "b", "c"}, NestedMap: map[string]int{"k1": 1, "k2": 2}}, + {NestedList: []string{"d", "e"}, NestedMap: map[string]int{"k3": 3}}, + }, + } + nativeVal := adapter.NativeToValue(nativeData) + + benchmarks := []struct { + name string + val any + }{ + {name: "scalar_int", val: Int(42)}, + {name: "scalar_string", val: String("hello world this is a test string")}, + {name: "native_string", val: "hello world this is a test string"}, + {name: "native_bytes", val: []byte("hello world this is a test string")}, + {name: "list_flat", val: flatList}, + {name: "list_nested", val: nestedList}, + {name: "map_flat", val: flatMap}, + {name: "custom_list_flat", val: proxyLegacyList{proxy: flatList}}, + {name: "custom_list_nested", val: proxyLegacyList{proxy: nestedList}}, + {name: "custom_map_flat", val: interopFoldableMap{Mapper: flatMap}}, + {name: "proto_message", val: protoMsg}, + {name: "proto_obj", val: adapter.NativeToValue(protoMsg)}, + {name: "native_obj", val: nativeVal}, + {name: "native_struct", val: nativeData}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ReportAllocs() + calculator := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calculator.AggregateSize(bm.val) + } + }) + } +} + +func BenchmarkCalculateSizeDynamic(b *testing.B) { + adapter := DefaultTypeAdapter + + benchmarks := []struct { + name string + valFn func() any + }{ + { + name: "list_flat", + valFn: func() any { + return NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3), Int(4), Int(5), Int(6), Int(7), Int(8)}) + }, + }, + { + name: "list_nested", + valFn: func() any { + return NewRefValList(adapter, []ref.Val{ + String("hello"), + NewRefValList(adapter, []ref.Val{String("nested1"), String("nested2")}), + NewRefValMap(adapter, map[ref.Val]ref.Val{String("k1"): String("v1")}), + }) + }, + }, + { + name: "map_flat", + valFn: func() any { + return NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("k1"): Int(1), + String("k2"): Int(2), + String("k3"): Int(3), + }) + }, + }, + { + name: "custom_list_flat", + valFn: func() any { + return proxyLegacyList{proxy: NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3), Int(4), Int(5), Int(6), Int(7), Int(8)})} + }, + }, + { + name: "custom_list_nested", + valFn: func() any { + return proxyLegacyList{proxy: NewRefValList(adapter, []ref.Val{ + String("hello"), + NewRefValList(adapter, []ref.Val{String("nested1"), String("nested2")}), + NewRefValMap(adapter, map[ref.Val]ref.Val{String("k1"): String("v1")}), + })} + }, + }, + { + name: "custom_map_flat", + valFn: func() any { + return interopFoldableMap{Mapper: NewRefValMap(adapter, map[ref.Val]ref.Val{ + String("k1"): Int(1), + String("k2"): Int(2), + String("k3"): Int(3), + })} + }, + }, + { + name: "proto_obj", + valFn: func() any { + return adapter.NativeToValue(&proto3pb.TestAllTypes{ + SingleString: "hello world", + SingleInt64: 42, + RepeatedString: []string{"first", "second", "third"}, + MapStringString: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }) + }, + }, + { + name: "native_obj", + valFn: func() any { + return adapter.NativeToValue(&rootNative{ + Name: "parent", + Count: 100, + Children: []nestedNative{ + {NestedList: []string{"a", "b", "c"}, NestedMap: map[string]int{"k1": 1, "k2": 2}}, + {NestedList: []string{"d", "e"}, NestedMap: map[string]int{"k3": 3}}, + }, + }) + }, + }, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ReportAllocs() + calculator := NewSizeCalculator() + for i := 0; i < b.N; i++ { + val := bm.valFn() + _ = calculator.AggregateSize(val) + } + }) + } +} + +func BenchmarkCalculateSizeScaled(b *testing.B) { + adapter := DefaultTypeAdapter + sizes := []int{10, 100, 1000} + + for _, size := range sizes { + // Prepare list elements + listElems := make([]ref.Val, size) + for i := 0; i < size; i++ { + listElems[i] = Int(i) + } + builtinList := NewRefValList(adapter, listElems) + customList := proxyLegacyList{proxy: builtinList} + + // Prepare map entries + mapEntries := make(map[ref.Val]ref.Val, size) + for i := 0; i < size; i++ { + mapEntries[String(fmt.Sprintf("k%d", i))] = Int(i) + } + builtinMap := NewRefValMap(adapter, mapEntries) + customMap := interopFoldableMap{Mapper: builtinMap} + + // Amortized (repeated calculation on memoized vs unmemoized custom instance) + b.Run(fmt.Sprintf("Amortized/builtin_list/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(builtinList) + } + }) + b.Run(fmt.Sprintf("Amortized/custom_list/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(customList) + } + }) + b.Run(fmt.Sprintf("Amortized/builtin_map/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(builtinMap) + } + }) + b.Run(fmt.Sprintf("Amortized/custom_map/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(customMap) + } + }) + b.Run(fmt.Sprintf("Amortized/custom_pure_map/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + pureMap := customPureMapper{Mapper: builtinMap} + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(pureMap) + } + }) + + // First-time / Uncached calculation + b.Run(fmt.Sprintf("FirstTime/builtin_list/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + l := NewRefValList(adapter, listElems) + _ = calc.AggregateSize(l) + } + }) + b.Run(fmt.Sprintf("FirstTime/custom_list/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + l := proxyLegacyList{proxy: NewRefValList(adapter, listElems)} + _ = calc.AggregateSize(l) + } + }) + b.Run(fmt.Sprintf("FirstTime/builtin_map/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + m := NewRefValMap(adapter, mapEntries) + _ = calc.AggregateSize(m) + } + }) + b.Run(fmt.Sprintf("FirstTime/custom_map/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + m := interopFoldableMap{Mapper: NewRefValMap(adapter, mapEntries)} + _ = calc.AggregateSize(m) + } + }) + b.Run(fmt.Sprintf("FirstTime/custom_pure_map/N=%d", size), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + m := customPureMapper{Mapper: NewRefValMap(adapter, mapEntries)} + _ = calc.AggregateSize(m) + } + }) + } + + // Benchmark nested tree complexity (Depth x Width) + depths := []int{2, 3} + width := 10 + for _, depth := range depths { + builtinNested := createNestedList(adapter, depth, width) + customNested := createNestedCustomList(adapter, depth, width) + + b.Run(fmt.Sprintf("Complexity/builtin_nested_list/Depth=%d_Width=%d", depth, width), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(builtinNested) + } + }) + b.Run(fmt.Sprintf("Complexity/custom_nested_list/Depth=%d_Width=%d", depth, width), func(b *testing.B) { + b.ReportAllocs() + calc := NewSizeCalculator() + for i := 0; i < b.N; i++ { + _ = calc.AggregateSize(customNested) + } + }) + } +} + +func createNestedList(adapter Adapter, depth, width int) ref.Val { + if depth <= 1 { + elems := make([]ref.Val, width) + for i := 0; i < width; i++ { + elems[i] = Int(i) + } + return NewRefValList(adapter, elems) + } + elems := make([]ref.Val, width) + for i := 0; i < width; i++ { + elems[i] = createNestedList(adapter, depth-1, width) + } + return NewRefValList(adapter, elems) +} + +func createNestedCustomList(adapter Adapter, depth, width int) ref.Val { + if depth <= 1 { + elems := make([]ref.Val, width) + for i := 0; i < width; i++ { + elems[i] = Int(i) + } + return proxyLegacyList{proxy: NewRefValList(adapter, elems)} + } + elems := make([]ref.Val, width) + for i := 0; i < width; i++ { + elems[i] = createNestedCustomList(adapter, depth-1, width) + } + return proxyLegacyList{proxy: NewRefValList(adapter, elems)} +} + +func TestSizeCalculatorStringUnitLength(t *testing.T) { + tests := []struct { + name string + opts []SizeCalculatorOption + val any + want uint32 + }{ + {name: "empty_string_unit", val: String(""), want: 1}, + {name: "one_unit_exact", val: String("0123456789"), want: 1}, + {name: "one_unit_plus_one", val: String("0123456789a"), want: 2}, + {name: "three_units", val: String("0123456789012345678901"), want: 3}, + {name: "bytes_two_units", val: Bytes("01234567890"), want: 2}, + {name: "native_string_two_units", val: "01234567890", want: 2}, + {name: "native_bytes_two_units", val: []byte("01234567890"), want: 2}, + {name: "reflect_string_two_units", val: reflect.ValueOf("01234567890"), want: 2}, + { + name: "unit_length_one", + opts: []SizeCalculatorOption{SizeCalculatorStringUnitLength(1)}, + val: String("hello"), + want: 5, + }, + { + name: "unit_length_below_one_clamped", + opts: []SizeCalculatorOption{SizeCalculatorStringUnitLength(0)}, + val: String("hello"), + want: 5, + }, + { + name: "unit_length_large", + opts: []SizeCalculatorOption{SizeCalculatorStringUnitLength(100)}, + val: String("hello world, hello world, hello world"), + want: 1, + }, + { + // Sizes are measured in bytes, not characters: four 3-byte CJK characters + // occupy 12 bytes and count as two 10-byte units. + name: "multibyte_counted_in_bytes", + val: String("日本語字"), + want: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + calc := NewSizeCalculator(tc.opts...) + if got := calc.AggregateSize(tc.val); got != tc.want { + t.Errorf("AggregateSize(%v) got %d, want %d", tc.val, got, tc.want) + } + }) + } +} + +func TestEstimateAggregateSize(t *testing.T) { + adapter := DefaultTypeAdapter + + t.Run("within_limits", func(t *testing.T) { + calc := NewSizeCalculator() + est := calc.EstimateAggregateSize(NewRefValList(adapter, []ref.Val{Int(1), Int(2)})) + if est.Size != 3 || est.LimitExceeded { + t.Errorf("EstimateAggregateSize() got %+v, want {Size: 3, LimitExceeded: false}", est) + } + }) + + t.Run("traversal_limit_exceeded", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxTraversal(2)) + list := NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3)}) + est := calc.EstimateAggregateSize(list) + if est.Size != math.MaxUint32 || !est.LimitExceeded { + t.Errorf("EstimateAggregateSize() got %+v, want {Size: MaxUint32, LimitExceeded: true}", est) + } + }) + + t.Run("depth_limit_exceeded", func(t *testing.T) { + calc := NewSizeCalculator(SizeCalculatorMaxDepth(1)) + list := NewRefValList(adapter, []ref.Val{NewRefValList(adapter, []ref.Val{Int(1)})}) + est := calc.EstimateAggregateSize(list) + if est.Size != math.MaxUint32 || !est.LimitExceeded { + t.Errorf("EstimateAggregateSize() got %+v, want {Size: MaxUint32, LimitExceeded: true}", est) + } + }) + + t.Run("saturation_without_limit", func(t *testing.T) { + // Two custom sizers each reporting MaxUint32 elements saturate the sum without + // tripping the depth or traversal limits. + calc := NewSizeCalculator() + val := struct{ A, B traits.Sizer }{ + A: customSizerVal(math.MaxUint32), + B: customSizerVal(math.MaxUint32), + } + est := calc.EstimateAggregateSize(val) + if est.Size != math.MaxUint32 || est.LimitExceeded { + t.Errorf("EstimateAggregateSize() got %+v, want {Size: MaxUint32, LimitExceeded: false}", est) + } + }) +} + +func TestAggregateSizeConcurrentAccess(t *testing.T) { + // Immutable lists and maps may be shared across concurrent evaluations; the aggregate + // size memoization must be race-free (validated under `go test -race`). + adapter := DefaultTypeAdapter + sharedList := NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3)}) + sharedMap := NewRefValMap(adapter, map[ref.Val]ref.Val{String("k"): String("v")}) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + calc := NewSizeCalculator() + if got := calc.AggregateSize(sharedList); got != 4 { + t.Errorf("AggregateSize(list) got %d, want 4", got) + } + if got := calc.AggregateSize(sharedMap); got != 3 { + t.Errorf("AggregateSize(map) got %d, want 3", got) + } + }() + } + wg.Wait() +} + +func TestAggregateSizeAbortedComputationNotMemoized(t *testing.T) { + // A sizing aborted at the calculator's limits depends on where in the traversal the + // value was encountered and must not be memoized: a later sizing within limits must + // return the true size with no limit-exceeded signal. + adapter := DefaultTypeAdapter + + t.Run("list", func(t *testing.T) { + shared := NewRefValList(adapter, []ref.Val{Int(1), Int(2), Int(3), Int(4), Int(5)}) + strict := NewSizeCalculator(SizeCalculatorMaxTraversal(2)) + if est := strict.EstimateAggregateSize(shared); !est.LimitExceeded { + t.Fatalf("strict EstimateAggregateSize() got %+v, want LimitExceeded", est) + } + generous := NewSizeCalculator() + est := generous.EstimateAggregateSize(shared) + if est.Size != 6 || est.LimitExceeded { + t.Errorf("generous EstimateAggregateSize() got %+v, want {Size: 6, LimitExceeded: false}", est) + } + }) + + t.Run("map", func(t *testing.T) { + shared := NewRefValMap(adapter, map[ref.Val]ref.Val{ + Int(1): Int(2), + Int(3): Int(4), + }) + strict := NewSizeCalculator(SizeCalculatorMaxTraversal(2)) + if est := strict.EstimateAggregateSize(shared); !est.LimitExceeded { + t.Fatalf("strict EstimateAggregateSize() got %+v, want LimitExceeded", est) + } + generous := NewSizeCalculator() + est := generous.EstimateAggregateSize(shared) + if est.Size != 5 || est.LimitExceeded { + t.Errorf("generous EstimateAggregateSize() got %+v, want {Size: 5, LimitExceeded: false}", est) + } + }) + + t.Run("completed_computation_is_memoized", func(t *testing.T) { + shared := NewRefValList(adapter, []ref.Val{Int(1), Int(2)}) + calc := NewSizeCalculator() + if got := calc.AggregateSize(shared); got != 3 { + t.Fatalf("AggregateSize() got %d, want 3", got) + } + // A subsequent sizing under a stricter budget serves the memoized total rather + // than recomputing (and aborting). + strict := NewSizeCalculator(SizeCalculatorMaxTraversal(2)) + est := strict.EstimateAggregateSize(shared) + if est.Size != 3 || est.LimitExceeded { + t.Errorf("strict EstimateAggregateSize() after memoization got %+v, want {Size: 3, LimitExceeded: false}", est) + } + }) +} diff --git a/common/types/struct.go b/common/types/struct.go new file mode 100644 index 000000000..49790e66e --- /dev/null +++ b/common/types/struct.go @@ -0,0 +1,39 @@ +// 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 +// +// 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 types + +import ( + "reflect" + + "github.com/authzed/cel-go/common/types/ref" +) + +// StructTypeDescriptor describes a CEL struct type, providing field metadata and value instantiation. +type StructTypeDescriptor interface { + // ReflectType returns the backing Go reflect.Type associated with the struct (or nil if non-reflected). + ReflectType() reflect.Type + + // FieldNames returns the list of field names defined on the struct. + FieldNames() []string + + // FindFieldType returns the field type and a boolean indicating if the field exists. + FindFieldType(fieldName string) (*FieldType, bool) + + // NewValue creates a new CEL struct value from the given map of field values. + NewValue(adapter Adapter, fields map[string]ref.Val) ref.Val + + // Adapt converts a native Go value (struct instance or pointer) to a CEL ref.Val. + Adapt(adapter Adapter, value any) ref.Val +} diff --git a/common/types/timestamp.go b/common/types/timestamp.go index c830851eb..91ed60ebb 100644 --- a/common/types/timestamp.go +++ b/common/types/timestamp.go @@ -15,6 +15,8 @@ package types import ( + "encoding/json" + "errors" "fmt" "reflect" "regexp" @@ -258,6 +260,90 @@ func (t Timestamp) format(sb *strings.Builder) { fmt.Fprintf(sb, `timestamp("%s")`, t.Time.UTC().Format(time.RFC3339Nano)) } +// ParseTimestamp attempts to parse a timestamp from various supported types and representations: +// - time.Time, Timestamp, *timestamppb.Timestamp +// - RFC 3339 and RFC 3339Nano formatted strings (e.g. "2023-01-01T00:00:00Z") +// - Unix epoch integers (int, int32, int64) +// - Unix epoch floating-point seconds (float32, float64) +// - json.Number +// - String representations of integers or floating-point epoch seconds +// +// If the parsed timestamp falls outside the supported range [minUnixTime, maxUnixTime], an error is returned. +func ParseTimestamp(val any) (time.Time, error) { + if val == nil { + return time.Time{}, errors.New("invalid timestamp: nil value") + } + switch v := val.(type) { + case time.Time: + return validateTimestampRange(v.UTC()) + case Timestamp: + return validateTimestampRange(v.Time.UTC()) + case *tpb.Timestamp: + if v == nil { + return time.Time{}, nil + } + return validateTimestampRange(v.AsTime().UTC()) + case int: + return validateTimestampRange(time.Unix(int64(v), 0).UTC()) + case int32: + return validateTimestampRange(time.Unix(int64(v), 0).UTC()) + case int64: + return validateTimestampRange(time.Unix(v, 0).UTC()) + case float32: + return unixTimeFromFloat(float64(v)) + case float64: + return unixTimeFromFloat(v) + case json.Number: + if i, err := v.Int64(); err == nil { + return validateTimestampRange(time.Unix(i, 0).UTC()) + } + if f, err := v.Float64(); err == nil { + return unixTimeFromFloat(f) + } + return ParseTimestamp(v.String()) + case string: + s := strings.TrimSpace(v) + if s == "" { + return time.Time{}, errors.New("invalid RFC 3339 timestamp: ''") + } + if isStrictRFC3339(s) { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, fmt.Errorf("invalid RFC 3339 timestamp %q", s) + } + return validateTimestampRange(t.UTC()) + } + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return validateTimestampRange(time.Unix(i, 0).UTC()) + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return unixTimeFromFloat(f) + } + return time.Time{}, fmt.Errorf("unsupported timestamp format: %q", s) + default: + return time.Time{}, fmt.Errorf("unsupported timestamp type: %T", val) + } +} + +func unixTimeFromFloat(f float64) (time.Time, error) { + sec, err := doubleToInt64Checked(f) + if err != nil { + return time.Time{}, err + } + nsec := int64((f - float64(sec)) * 1e9) + return validateTimestampRange(time.Unix(sec, nsec).UTC()) +} + +func validateTimestampRange(t time.Time) (time.Time, error) { + if t.IsZero() { + return t, nil + } + if t.Unix() < minUnixTime || t.Unix() > maxUnixTime { + return time.Time{}, fmt.Errorf("timestamp overflow: %v", t) + } + return t, nil +} + var ( timestampValueType = reflect.TypeOf(&tpb.Timestamp{}) @@ -368,7 +454,7 @@ func timeZone(tz ref.Val, visitor timestampVisitor) timestampVisitor { } // If the input is not the name of a timezone (for example, 'US/Central'), it should be a numerical offset from UTC - // in the format ^(+|-)(0[0-9]|1[0-4]):[0-5][0-9]$. The numerical input is parsed in terms of hours and minutes. + // in the format ^(+|-)([01]\d|2[0-3]):[0-5][0-9]$. The numerical input is parsed in terms of hours and minutes. hr, err := strconv.Atoi(string(val[0:ind])) if err != nil { return WrapErr(err) @@ -377,6 +463,9 @@ func timeZone(tz ref.Val, visitor timestampVisitor) timestampVisitor { if err != nil { return WrapErr(err) } + if hr < -23 || hr > 23 { + return WrapErr(fmt.Errorf("timezone offset hours out of range [-23, 23]: %s", val)) + } if min < 0 || min > 59 { return WrapErr(fmt.Errorf("timezone offset minutes out of range [0, 59]: %s", val)) } diff --git a/common/types/timestamp_test.go b/common/types/timestamp_test.go index 615f94f17..46240cc71 100644 --- a/common/types/timestamp_test.go +++ b/common/types/timestamp_test.go @@ -15,6 +15,7 @@ package types import ( + "encoding/json" "errors" "math" "reflect" @@ -439,6 +440,13 @@ func TestTimestampGetHours(t *testing.T) { if !hrTz.Equal(Int(19)).(Bool) { t.Errorf("ts.getHours('America/Phoenix') got %v, wanted 19 hours", hrTz) } + // Out-of-range hour offsets are rejected rather than silently shifting the instant. + for _, tz := range []string{"+24:00", "-24:00", "+99:00", "-50:30"} { + if got := ts.Receive(overloads.TimeGetHours, overloads.TimestampToHoursWithTz, + []ref.Val{String(tz)}); !IsError(got) { + t.Errorf("ts.getHours(%q) got %v, wanted error", tz, got) + } + } } func TestTimestampGetMinutes(t *testing.T) { @@ -544,3 +552,178 @@ func TestIsStrictRFC3339MatchesPattern(t *testing.T) { } } } + +func TestParseTimestamp(t *testing.T) { + now := time.Now().UTC() + epoch := int64(1700000000) + epochTime := time.Unix(epoch, 0).UTC() + epochFloatTime := time.Unix(epoch, 500000000).UTC() + var nilPbTs *tpb.Timestamp + + tests := []struct { + name string + val any + want time.Time + wantErr bool + }{ + { + name: "nil", + val: nil, + wantErr: true, + }, + { + name: "empty string", + val: "", + wantErr: true, + }, + { + name: "time.Time", + val: now, + want: now, + }, + { + name: "Timestamp struct", + val: Timestamp{Time: now}, + want: now, + }, + { + name: "*tpb.Timestamp", + val: tpb.New(now), + want: now, + }, + { + name: "nil *tpb.Timestamp", + val: nilPbTs, + want: time.Time{}, + }, + { + name: "int", + val: int(epoch), + want: epochTime, + }, + { + name: "int32", + val: int32(epoch), + want: epochTime, + }, + { + name: "int64", + val: int64(epoch), + want: epochTime, + }, + { + name: "float64", + val: float64(1700000000.5), + want: epochFloatTime, + }, + { + name: "float64 negative", + val: float64(-1700000000.5), + want: time.Unix(-1700000000, -500000000).UTC(), + }, + { + name: "float64 MaxFloat64 overflow", + val: math.MaxFloat64, + wantErr: true, + }, + { + name: "float64 NaN overflow", + val: math.NaN(), + wantErr: true, + }, + { + name: "float64 Inf overflow", + val: math.Inf(1), + wantErr: true, + }, + { + name: "float64 -Inf overflow", + val: math.Inf(-1), + wantErr: true, + }, + { + name: "float32", + val: float32(1700000000.5), + want: epochTime, + }, + { + name: "float32 negative", + val: float32(-1700000000.5), + want: time.Unix(-1700000000, 0).UTC(), + }, + { + name: "json.Number int", + val: json.Number("1700000000"), + want: epochTime, + }, + { + name: "json.Number float", + val: json.Number("1700000000.5"), + want: epochFloatTime, + }, + { + name: "json.Number invalid", + val: json.Number("invalid"), + wantErr: true, + }, + { + name: "string RFC3339", + val: "2026-08-10T12:00:00Z", + want: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC), + }, + { + name: "string RFC3339Nano", + val: "2026-08-10T12:00:00.500Z", + want: time.Date(2026, 8, 10, 12, 0, 0, 500000000, time.UTC), + }, + { + name: "string RFC3339 invalid", + val: "2026-99-99T99:99:99Z", + wantErr: true, + }, + { + name: "string epoch int", + val: "1700000000", + want: epochTime, + }, + { + name: "string epoch float", + val: "1700000000.5", + want: epochFloatTime, + }, + { + name: "string invalid", + val: "not-a-timestamp", + wantErr: true, + }, + { + name: "unsupported map type", + val: map[string]any{}, + wantErr: true, + }, + { + name: "overflow", + val: int64(999999999999999), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts, err := ParseTimestamp(tc.val) + if tc.wantErr { + if err == nil { + t.Errorf("ParseTimestamp(%v) succeeded, wanted error", tc.val) + } + return + } + if err != nil { + t.Errorf("ParseTimestamp(%v) unexpected error: %v", tc.val, err) + return + } + if !ts.Equal(tc.want) { + t.Errorf("ParseTimestamp(%v) = %v, wanted %v", tc.val, ts, tc.want) + } + }) + } +} diff --git a/common/types/util_test.go b/common/types/util_test.go index b10b3e84c..4d16495ee 100644 --- a/common/types/util_test.go +++ b/common/types/util_test.go @@ -14,7 +14,42 @@ package types -import "testing" +import ( + "math" + "testing" +) + +func TestSafeUint32Helpers(t *testing.T) { + // safeAddUint32 + if got := safeAddUint32(10, 20); got != 30 { + t.Errorf("safeAddUint32(10, 20) got %d, want 30", got) + } + if got := safeAddUint32(math.MaxUint32-5, 10); got != math.MaxUint32 { + t.Errorf("safeAddUint32(overflow) got %d, want MaxUint32", got) + } + + // safeUint32FromInt + if got := safeUint32FromInt(42); got != 42 { + t.Errorf("safeUint32FromInt(42) got %d, want 42", got) + } + if got := safeUint32FromInt(-1); got != math.MaxUint32 { + t.Errorf("safeUint32FromInt(-1) got %d, want MaxUint32", got) + } + if got := safeUint32FromInt(int(uint64(math.MaxUint32) + 100)); got != math.MaxUint32 { + t.Errorf("safeUint32FromInt(overflow) got %d, want MaxUint32", got) + } + + // safeUint32FromBoxedInt + if got := safeUint32FromBoxedInt(Int(42)); got != 42 { + t.Errorf("safeUint32FromBoxedInt(42) got %d, want 42", got) + } + if got := safeUint32FromBoxedInt(Int(-1)); got != math.MaxUint32 { + t.Errorf("safeUint32FromBoxedInt(-1) got %d, want MaxUint32", got) + } + if got := safeUint32FromBoxedInt(Int(int64(math.MaxUint32) + 100)); got != math.MaxUint32 { + t.Errorf("safeUint32FromBoxedInt(overflow) got %d, want MaxUint32", got) + } +} func BenchmarkIsUnknownOrError(b *testing.B) { err := NewErr("test") diff --git a/conformance/BUILD.bazel b/conformance/BUILD.bazel index 455f6e695..b30f1c1d3 100644 --- a/conformance/BUILD.bazel +++ b/conformance/BUILD.bazel @@ -17,6 +17,7 @@ _ALL_TESTS = [ "@dev_cel_expr//tests/simple:testdata/fp_math.textproto", "@dev_cel_expr//tests/simple:testdata/integer_math.textproto", "@dev_cel_expr//tests/simple:testdata/lists.textproto", + "@dev_cel_expr//tests/simple:testdata/lists_ext.textproto", "@dev_cel_expr//tests/simple:testdata/logic.textproto", "@dev_cel_expr//tests/simple:testdata/macros.textproto", "@dev_cel_expr//tests/simple:testdata/macros2.textproto", diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go index 1f7c0b43f..6f95ef6b8 100644 --- a/conformance/conformance_test.go +++ b/conformance/conformance_test.go @@ -88,6 +88,7 @@ func init() { cel.Types(&test2pb.TestAllTypes{}, &test2pb.Proto2ExtensionScopedMessage{}, &test3pb.TestAllTypes{}), ext.Bindings(), ext.Encoders(), + ext.Lists(), ext.Math(), ext.Protos(), ext.Strings(), diff --git a/conformance/go.mod b/conformance/go.mod index 15bd36b6a..971d12421 100644 --- a/conformance/go.mod +++ b/conformance/go.mod @@ -3,20 +3,26 @@ module github.com/authzed/cel-go/conformance go 1.23.0 require ( + github.com/authzed/cel-go v0.26.1 + github.com/authzed/cel-go/policy v0.0.0-20250311174852-f5ea07b389a1 + github.com/authzed/cel-go/tools v0.0.0-20251023215754-a36d461be521 cel.dev/expr v0.25.1 github.com/bazelbuild/rules_go v0.49.0 - github.com/authzed/cel-go v0.26.1 github.com/google/go-cmp v0.7.0 google.golang.org/protobuf v1.36.10 ) require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/text v0.22.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250311190419-81fb87f6b8bf // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250311190419-81fb87f6b8bf // indirect ) replace github.com/authzed/cel-go => ./.. + +replace github.com/authzed/cel-go/policy => ../policy + +replace github.com/authzed/cel-go/tools => ../tools diff --git a/conformance/go.sum b/conformance/go.sum index 3c87f38d3..a73997939 100644 --- a/conformance/go.sum +++ b/conformance/go.sum @@ -4,35 +4,19 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/bazelbuild/rules_go v0.49.0 h1:5vCbuvy8Q11g41lseGJDc5vxhDjJtfxr6nM/IC4VmqM= github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= -google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/api v0.0.0-20250311190419-81fb87f6b8bf h1:BdIVRm+fyDUn8lrZLPSlBCfM/YKDwUBYgDoLv9+DYo0= +google.golang.org/genproto/googleapis/api v0.0.0-20250311190419-81fb87f6b8bf/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250311190419-81fb87f6b8bf h1:dHDlF3CWxQkefK9IJx+O8ldY0gLygvrlYRBNbPqDWuY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250311190419-81fb87f6b8bf/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ext/BUILD.bazel b/ext/BUILD.bazel index 3d8631351..f172478f0 100644 --- a/ext/BUILD.bazel +++ b/ext/BUILD.bazel @@ -31,6 +31,7 @@ go_library( "//checker:go_default_library", "//common:go_default_library", "//common/ast:go_default_library", + "//common/cost:go_default_library", "//common/decls:go_default_library", "//common/env:go_default_library", "//common/operators:go_default_library", diff --git a/ext/costs.go b/ext/costs.go index bfec2cb9e..ec2323eec 100644 --- a/ext/costs.go +++ b/ext/costs.go @@ -66,6 +66,8 @@ func actualSize(value ref.Val) uint64 { return 1 } +// nodeAsUintValue returns the value of a literal int node as a uint64, or the default value if the +// node is not a non-negative int literal. func nodeAsUintValue(node checker.AstNode, defaultVal uint64) uint64 { if node.Expr().Kind() != ast.LiteralKind { return defaultVal @@ -102,21 +104,3 @@ func atLeastOne(size checker.SizeEstimate) checker.SizeEstimate { } return size } - -func safeAdd(x, y uint64, rest ...uint64) uint64 { - if y > 0 && x > math.MaxUint64-y { - return math.MaxUint64 - } - next := x + y - if len(rest) == 0 { - return next - } - return safeAdd(next, rest[0], rest[1:]...) -} - -func safeMul(x, y uint64) uint64 { - if y != 0 && x > math.MaxUint64/y { - return math.MaxUint64 - } - return x * y -} diff --git a/ext/encoders.go b/ext/encoders.go index 302e9aefe..8535da374 100644 --- a/ext/encoders.go +++ b/ext/encoders.go @@ -22,6 +22,7 @@ import ( "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/checker" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" "github.com/authzed/cel-go/interpreter" @@ -184,8 +185,8 @@ func estimateDecode(estimator checker.CostEstimator, target *checker.AstNode, ar func trackEncode(args []ref.Val, _ ref.Val) *uint64 { sz := actualSize(args[0]) - cost := uint64(math.Ceil(float64(sz)*stringCostFactor)) + callCost - return &cost + total := cost.SafeAdd(cost.SafeMultiplyByFactor(sz, stringCostFactor), callCost) + return &total } func trackJSONEncode(args []ref.Val, _ ref.Val) *uint64 { @@ -195,8 +196,8 @@ func trackJSONEncode(args []ref.Val, _ ref.Val) *uint64 { func trackDecode(args []ref.Val, _ ref.Val) *uint64 { sz := actualSize(args[0]) - cost := uint64(math.Ceil(float64(sz)*stringCostFactor)) + callCost - return &cost + total := cost.SafeAdd(cost.SafeMultiplyByFactor(sz, stringCostFactor), callCost) + return &total } func estimateEncodeSize(sz checker.SizeEstimate) checker.SizeEstimate { diff --git a/ext/lists.go b/ext/lists.go index af1e3adf0..5f19ead44 100644 --- a/ext/lists.go +++ b/ext/lists.go @@ -23,6 +23,7 @@ import ( "github.com/authzed/cel-go/checker" "github.com/authzed/cel-go/common" "github.com/authzed/cel-go/common/ast" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/decls" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" @@ -192,7 +193,7 @@ func ListsVersion(version uint32) ListsOption { } // ListsMaxRangeSize sets the maximum number of elements lists.range() will -// allocate. If not set, the default is 10,000,000. Setting this to zero +// allocate. If not set, the default is 1,000,000. Setting this to zero // disables the limit (not recommended). func ListsMaxRangeSize(size int64) ListsOption { return func(lib *listsLib) *listsLib { @@ -893,7 +894,7 @@ func trackListSelfCompare(l traits.Lister) *uint64 { if elem.Type() == types.StringType || elem.Type() == types.BytesType { costFactor += common.StringTraversalCostFactor } - return trackAllocatingListCall(costFactor, safeMul(sz, sz)) + return trackAllocatingListCall(costFactor, cost.SafeMultiply(sz, sz)) } // trackAllocatingListCall computes costs as a function of the size of the result list with a baseline cost @@ -902,8 +903,8 @@ func trackAllocatingListCall(costFactor float64, size uint64) *uint64 { if costFactor < 0.0 { costFactor = 1.0 } - cost := safeAdd(uint64(float64(size)*costFactor), callCost, common.ListCreateBaseCost) - return &cost + total := cost.SafeAdd(uint64(float64(size)*costFactor), callCost, common.ListCreateBaseCost) + return &total } func estimateListDistinctLegacy(estimator checker.CostEstimator, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { diff --git a/ext/math.go b/ext/math.go index fb1859adc..6f037b652 100644 --- a/ext/math.go +++ b/ext/math.go @@ -22,6 +22,7 @@ import ( "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/checker" "github.com/authzed/cel-go/common/ast" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" "github.com/authzed/cel-go/common/types/traits" @@ -982,6 +983,6 @@ func estimateMathListCost(estimator checker.CostEstimator, target *checker.AstNo func trackMathListCost(args []ref.Val, _ ref.Val) *uint64 { sz := actualSize(args[0]) - cost := safeAdd(sz, callCost) - return &cost + total := cost.SafeAdd(sz, callCost) + return &total } diff --git a/ext/native.go b/ext/native.go index 81f3e9279..42719cab1 100644 --- a/ext/native.go +++ b/ext/native.go @@ -15,31 +15,38 @@ package ext import ( - "errors" - "fmt" - "math" - "reflect" - "strings" - "time" - - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/reflect/protoreflect" - "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/common/types" - "github.com/authzed/cel-go/common/types/pb" - "github.com/authzed/cel-go/common/types/ref" - "github.com/authzed/cel-go/common/types/traits" - - structpb "google.golang.org/protobuf/types/known/structpb" ) +// NativeTypesOption is a functional interface for configuring handling of native types. +type NativeTypesOption = types.NativeTypeOption + +// NativeTypesFieldNameHandler is a handler for mapping a reflect.StructField to a CEL field name. +// This can be used to override the default Go struct field to CEL field name mapping. +type NativeTypesFieldNameHandler = types.NativeTypesFieldNameHandler + var ( - nativeObjTraitMask = traits.FieldTesterType | traits.IndexerType - jsonValueType = reflect.TypeOf(&structpb.Value{}) - jsonStructType = reflect.TypeOf(&structpb.Struct{}) + // ParseStructTags configures if native types field names should be overridable by CEL struct tags. + // This is equivalent to ParseStructTag("cel") + ParseStructTags = types.ParseStructTags + + // ParseStructTag configures the struct tag to parse. The 0th item in the tag is used as the name of the CEL field. + ParseStructTag = types.ParseStructTag + + // ParseStructField configures how to parse Go struct fields. It can be used to customize struct field parsing. + ParseStructField = types.ParseStructField ) +// NativeTypesVersion sets the native types version support for native extensions functions. +// +// Deprecated: NativeTypesVersion is a no-op and will be removed in a future release. +func NativeTypesVersion(version uint32) NativeTypesOption { + return func(*types.NativeTypeOptions) error { + return nil + } +} + // NativeTypes creates a type provider which uses reflect.Type and reflect.Value instances // to produce type definitions that can be used within CEL. // @@ -98,730 +105,14 @@ var ( // In case there are duplicated field names in the struct, an error will be returned. func NativeTypes(args ...any) cel.EnvOption { return func(env *cel.Env) (*cel.Env, error) { - nativeTypes := make([]any, 0, len(args)) - tpOptions := nativeTypeOptions{ - version: math.MaxUint32, - } - - for _, v := range args { - switch v := v.(type) { - case NativeTypesOption: - err := v(&tpOptions) - if err != nil { - return nil, err - } - default: - nativeTypes = append(nativeTypes, v) - } - } - - tp, err := newNativeTypeProvider(tpOptions, env.CELTypeAdapter(), env.CELTypeProvider(), nativeTypes...) + p, a, err := types.ComposeTypes(env.CELTypeProvider(), env.CELTypeAdapter(), args...) if err != nil { return nil, err } - - env, err = cel.CustomTypeAdapter(tp)(env) + env, err = cel.CustomTypeAdapter(a)(env) if err != nil { return nil, err } - return cel.CustomTypeProvider(tp)(env) - } -} - -// NativeTypesOption is a functional interface for configuring handling of native types. -type NativeTypesOption func(*nativeTypeOptions) error - -// NativeTypesVersion sets the native types version support for native extensions functions. -func NativeTypesVersion(version uint32) NativeTypesOption { - return func(opts *nativeTypeOptions) error { - opts.version = version - return nil - } -} - -// NativeTypesFieldNameHandler is a handler for mapping a reflect.StructField to a CEL field name. -// This can be used to override the default Go struct field to CEL field name mapping. -type NativeTypesFieldNameHandler = func(field reflect.StructField) string - -func fieldNameByTag(structTagToParse string) func(field reflect.StructField) string { - return func(field reflect.StructField) string { - tag, found := field.Tag.Lookup(structTagToParse) - if found { - splits := strings.Split(tag, ",") - if len(splits) > 0 { - // We make the assumption that the leftmost entry in the tag is the name. - // This seems to be true for most tags that have the concept of a name/key, such as: - // https://pkg.go.dev/encoding/xml#Marshal - // https://pkg.go.dev/encoding/json#Marshal - // https://pkg.go.dev/go.mongodb.org/mongo-driver/bson#hdr-Structs - // https://pkg.go.dev/go.yaml.in/yaml/v3#Marshal - name := splits[0] - return name - } - } - - return field.Name + return cel.CustomTypeProvider(p)(env) } } - -func isSkippedFieldName(name string) bool { - return name == "" || name == "-" -} - -type nativeTypeOptions struct { - // fieldNameHandler controls how CEL should perform struct field renames. - // This is most commonly used for switching to parsing based off the struct field tag, - // such as "cel" or "json". - fieldNameHandler NativeTypesFieldNameHandler - - // version is the native types library version. - version uint32 -} - -// ParseStructTags configures if native types field names should be overridable by CEL struct tags. -// This is equivalent to ParseStructTag("cel") -func ParseStructTags(enabled bool) NativeTypesOption { - return func(ntp *nativeTypeOptions) error { - if enabled { - ntp.fieldNameHandler = fieldNameByTag("cel") - } else { - ntp.fieldNameHandler = nil - } - return nil - } -} - -// ParseStructTag configures the struct tag to parse. The 0th item in the tag is used as the name of the CEL field. -// For example: -// If the tag to parse is "cel" and the struct field has tag cel:"foo", the CEL struct field will be "foo". -// If the tag to parse is "json" and the struct field has tag json:"foo,omitempty", the CEL struct field will be "foo". -func ParseStructTag(tag string) NativeTypesOption { - return func(ntp *nativeTypeOptions) error { - ntp.fieldNameHandler = fieldNameByTag(tag) - return nil - } -} - -// ParseStructField configures how to parse Go struct fields. It can be used to customize struct field parsing. -func ParseStructField(handler NativeTypesFieldNameHandler) NativeTypesOption { - return func(ntp *nativeTypeOptions) error { - ntp.fieldNameHandler = handler - return nil - } -} - -func newNativeTypeProvider(tpOptions nativeTypeOptions, adapter types.Adapter, provider types.Provider, refTypes ...any) (*nativeTypeProvider, error) { - nativeTypes := make(map[string]*nativeType, len(refTypes)) - for _, refType := range refTypes { - switch rt := refType.(type) { - case reflect.Type: - result, err := newNativeTypes(tpOptions.fieldNameHandler, rt) - if err != nil { - return nil, err - } - for idx := range result { - nativeTypes[result[idx].TypeName()] = result[idx] - } - case reflect.Value: - result, err := newNativeTypes(tpOptions.fieldNameHandler, rt.Type()) - if err != nil { - return nil, err - } - for idx := range result { - nativeTypes[result[idx].TypeName()] = result[idx] - } - default: - return nil, fmt.Errorf("unsupported native type: %v (%T) must be reflect.Type or reflect.Value", rt, rt) - } - } - return &nativeTypeProvider{ - nativeTypes: nativeTypes, - baseAdapter: adapter, - baseProvider: provider, - options: tpOptions, - }, nil -} - -type nativeTypeProvider struct { - nativeTypes map[string]*nativeType - baseAdapter types.Adapter - baseProvider types.Provider - options nativeTypeOptions -} - -// EnumValue proxies to the types.Provider configured at the times the NativeTypes -// option was configured. -func (tp *nativeTypeProvider) EnumValue(enumName string) ref.Val { - return tp.baseProvider.EnumValue(enumName) -} - -// FindIdent looks up natives type instances by qualified identifier, and if not found -// proxies to the composed types.Provider. -func (tp *nativeTypeProvider) FindIdent(typeName string) (ref.Val, bool) { - if t, found := tp.nativeTypes[typeName]; found { - return t, true - } - return tp.baseProvider.FindIdent(typeName) -} - -// FindStructType looks up the CEL type definition by qualified identifier, and if not found -// proxies to the composed types.Provider. -func (tp *nativeTypeProvider) FindStructType(typeName string) (*types.Type, bool) { - if _, found := tp.nativeTypes[typeName]; found { - return types.NewTypeTypeWithParam(types.NewObjectType(typeName)), true - } - if celType, found := tp.baseProvider.FindStructType(typeName); found { - return celType, true - } - return tp.baseProvider.FindStructType(typeName) -} - -func toFieldName(fieldNameHandler NativeTypesFieldNameHandler, f reflect.StructField) string { - if fieldNameHandler == nil { - return f.Name - } - - return fieldNameHandler(f) -} - -// FindStructFieldNames looks up the type definition first from the native types, then from -// the backing provider type set. If found, a set of field names corresponding to the type -// will be returned. -func (tp *nativeTypeProvider) FindStructFieldNames(typeName string) ([]string, bool) { - if t, found := tp.nativeTypes[typeName]; found { - fieldCount := t.refType.NumField() - fields := make([]string, 0, fieldCount) - for i := 0; i < fieldCount; i++ { - fieldName := toFieldName(tp.options.fieldNameHandler, t.refType.Field(i)) - if isSkippedFieldName(fieldName) { - continue - } - fields = append(fields, fieldName) - } - return fields, true - } - if celTypeFields, found := tp.baseProvider.FindStructFieldNames(typeName); found { - return celTypeFields, true - } - return tp.baseProvider.FindStructFieldNames(typeName) -} - -// FindStructFieldType looks up a native type's field definition, and if the type name is not a native -// type then proxies to the composed types.Provider -func (tp *nativeTypeProvider) FindStructFieldType(typeName, fieldName string) (*types.FieldType, bool) { - t, found := tp.nativeTypes[typeName] - if !found { - return tp.baseProvider.FindStructFieldType(typeName, fieldName) - } - refField, isDefined := t.hasField(fieldName) - if !found || !isDefined { - return nil, false - } - celType, ok := convertToCelType(refField.Type) - if !ok { - return nil, false - } - return &types.FieldType{ - Type: celType, - IsSet: func(obj any) bool { - refVal := reflect.Indirect(reflect.ValueOf(obj)) - refField := refVal.FieldByName(refField.Name) - return !refField.IsZero() - }, - GetFrom: func(obj any) (any, error) { - refVal := reflect.Indirect(reflect.ValueOf(obj)) - refField := refVal.FieldByName(refField.Name) - return getFieldValue(refField), nil - }, - }, true -} - -// NewValue implements the ref.TypeProvider interface method. -func (tp *nativeTypeProvider) NewValue(typeName string, fields map[string]ref.Val) ref.Val { - t, found := tp.nativeTypes[typeName] - if !found { - return tp.baseProvider.NewValue(typeName, fields) - } - refPtr := reflect.New(t.refType) - refVal := refPtr.Elem() - for fieldName, val := range fields { - refFieldDef, isDefined := t.hasField(fieldName) - if !isDefined { - return types.NewErr("no such field: %s", fieldName) - } - fieldVal, err := val.ConvertToNative(refFieldDef.Type) - if err != nil { - return types.NewErrFromString(err.Error()) - } - refField := refVal.FieldByIndex(refFieldDef.Index) - refFieldVal := reflect.ValueOf(fieldVal) - refField.Set(refFieldVal) - } - return tp.NativeToValue(refPtr.Interface()) -} - -// NewValue adapts native values to CEL values and will proxy to the composed type adapter -// for non-native types. -func (tp *nativeTypeProvider) NativeToValue(val any) ref.Val { - if val == nil { - return types.NullValue - } - if v, ok := val.(ref.Val); ok { - return v - } - rawVal := reflect.ValueOf(val) - refVal := rawVal - if refVal.Kind() == reflect.Ptr { - refVal = reflect.Indirect(refVal) - } - // This isn't quite right if you're also supporting proto, - // but maybe an acceptable limitation. - switch refVal.Kind() { - case reflect.Array, reflect.Slice: - switch val := val.(type) { - case []byte: - return tp.baseAdapter.NativeToValue(val) - default: - if refVal.Type().Elem() == reflect.TypeOf(byte(0)) { - return tp.baseAdapter.NativeToValue(val) - } - return types.NewDynamicList(tp, val) - } - case reflect.Map: - return types.NewDynamicMap(tp, val) - case reflect.Struct: - switch val := val.(type) { - case proto.Message, *pb.Map, protoreflect.List, protoreflect.Message, protoreflect.Value, - time.Time: - return tp.baseAdapter.NativeToValue(val) - default: - return tp.newNativeObject(val, rawVal) - } - default: - return tp.baseAdapter.NativeToValue(val) - } -} - -func convertToCelType(refType reflect.Type) (*cel.Type, bool) { - switch refType.Kind() { - case reflect.Bool: - return cel.BoolType, true - case reflect.Float32, reflect.Float64: - return cel.DoubleType, true - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if refType == durationType { - return cel.DurationType, true - } - return cel.IntType, true - case reflect.String: - return cel.StringType, true - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return cel.UintType, true - case reflect.Array, reflect.Slice: - refElem := refType.Elem() - if refElem == reflect.TypeOf(byte(0)) { - return cel.BytesType, true - } - elemType, ok := convertToCelType(refElem) - if !ok { - return nil, false - } - return cel.ListType(elemType), true - case reflect.Map: - keyType, ok := convertToCelType(refType.Key()) - if !ok { - return nil, false - } - // Ensure the key type is a int, bool, uint, string - elemType, ok := convertToCelType(refType.Elem()) - if !ok { - return nil, false - } - return cel.MapType(keyType, elemType), true - case reflect.Struct: - if refType == timestampType { - return cel.TimestampType, true - } - if refType.Implements(refValType) { - emptyCelVal := reflect.New(refType).Elem().Interface().(ref.Val) - return emptyCelVal.Type().(*cel.Type), true - } - return cel.ObjectType( - fmt.Sprintf("%s.%s", simplePkgAlias(refType.PkgPath()), refType.Name()), - ), true - case reflect.Pointer: - if refType.Implements(refValType) { - emptyCelVal := reflect.New(refType.Elem()).Interface().(ref.Val) - return emptyCelVal.Type().(*cel.Type), true - } - if refType.Implements(pbMsgInterfaceType) { - pbMsg := reflect.New(refType.Elem()).Interface().(protoreflect.ProtoMessage) - return cel.ObjectType(string(pbMsg.ProtoReflect().Descriptor().FullName())), true - } - return convertToCelType(refType.Elem()) - } - return nil, false -} - -func (tp *nativeTypeProvider) newNativeObject(val any, refValue reflect.Value) ref.Val { - valType, err := newNativeType(tp.options.fieldNameHandler, refValue.Type()) - if err != nil { - return types.NewErrFromString(err.Error()) - } - return &nativeObj{ - Adapter: tp, - val: val, - valType: valType, - refValue: refValue, - } -} - -type nativeObj struct { - types.Adapter - val any - valType *nativeType - refValue reflect.Value -} - -// ConvertToNative implements the ref.Val interface method. -// -// CEL does not have a notion of pointers, so whether a field is a pointer or value -// is handled as part of this conversion step. -func (o *nativeObj) ConvertToNative(typeDesc reflect.Type) (any, error) { - if o.refValue.Type() == typeDesc { - return o.val, nil - } - if o.refValue.Kind() == reflect.Pointer && o.refValue.Type().Elem() == typeDesc { - return o.refValue.Elem().Interface(), nil - } - if typeDesc.Kind() == reflect.Pointer && o.refValue.Type() == typeDesc.Elem() { - ptr := reflect.New(typeDesc.Elem()) - ptr.Elem().Set(o.refValue) - return ptr.Interface(), nil - } - switch typeDesc { - case jsonValueType: - jsonStruct, err := o.ConvertToNative(jsonStructType) - if err != nil { - return nil, err - } - return structpb.NewStructValue(jsonStruct.(*structpb.Struct)), nil - case jsonStructType: - refVal := reflect.Indirect(o.refValue) - refType := refVal.Type() - fields := make(map[string]*structpb.Value, refVal.NumField()) - for i := 0; i < refVal.NumField(); i++ { - fieldType := refType.Field(i) - fieldValue := refVal.Field(i) - if !fieldValue.IsValid() || fieldValue.IsZero() { - continue - } - fieldName := toFieldName(o.valType.fieldNameHandler, fieldType) - if isSkippedFieldName(fieldName) { - continue - } - fieldCELVal := o.NativeToValue(fieldValue.Interface()) - fieldJSONVal, err := fieldCELVal.ConvertToNative(jsonValueType) - if err != nil { - return nil, err - } - fields[fieldName] = fieldJSONVal.(*structpb.Value) - } - return &structpb.Struct{Fields: fields}, nil - } - return nil, fmt.Errorf("type conversion error from '%v' to '%v'", o.Type(), typeDesc) -} - -// ConvertToType implements the ref.Val interface method. -func (o *nativeObj) ConvertToType(typeVal ref.Type) ref.Val { - switch typeVal { - case types.TypeType: - return o.valType - default: - if typeVal.TypeName() == o.valType.typeName { - return o - } - } - return types.NewErr("type conversion error from '%s' to '%s'", o.Type(), typeVal) -} - -// Equal implements the ref.Val interface method. -// -// Note, that in Golang a pointer to a value is not equal to the value it contains. -// In CEL pointers and values to which they point are equal. -func (o *nativeObj) Equal(other ref.Val) ref.Val { - otherNtv, ok := other.(*nativeObj) - if !ok { - return types.False - } - val := o.val - otherVal := otherNtv.val - refVal := o.refValue - otherRefVal := otherNtv.refValue - if refVal.Kind() != otherRefVal.Kind() { - if refVal.Kind() == reflect.Pointer { - val = refVal.Elem().Interface() - } else if otherRefVal.Kind() == reflect.Pointer { - otherVal = otherRefVal.Elem().Interface() - } - } - return types.Bool(reflect.DeepEqual(val, otherVal)) -} - -// IsZeroValue indicates whether the contained Golang value is a zero value. -// -// Golang largely follows proto3 semantics for zero values. -func (o *nativeObj) IsZeroValue() bool { - return reflect.Indirect(o.refValue).IsZero() -} - -// IsSet tests whether a field which is defined is set to a non-default value. -func (o *nativeObj) IsSet(field ref.Val) ref.Val { - refField, refErr := o.getReflectedField(field) - if refErr != nil { - return refErr - } - return types.Bool(!refField.IsZero()) -} - -// Get returns the value fo a field name. -func (o *nativeObj) Get(field ref.Val) ref.Val { - refField, refErr := o.getReflectedField(field) - if refErr != nil { - return refErr - } - return adaptFieldValue(o, refField) -} - -func (o *nativeObj) getReflectedField(field ref.Val) (reflect.Value, ref.Val) { - fieldName, ok := field.(types.String) - if !ok { - return reflect.Value{}, types.MaybeNoSuchOverloadErr(field) - } - fieldNameStr := string(fieldName) - refField, isDefined := o.valType.hasField(fieldNameStr) - if !isDefined { - return reflect.Value{}, types.NewErr("no such field: %s", fieldName) - } - refVal := reflect.Indirect(o.refValue) - return refVal.FieldByIndex(refField.Index), nil -} - -// Type implements the ref.Val interface method. -func (o *nativeObj) Type() ref.Type { - return o.valType -} - -// Value implements the ref.Val interface method. -func (o *nativeObj) Value() any { - return o.val -} - -func newNativeTypes(fieldNameHandler NativeTypesFieldNameHandler, rawType reflect.Type) ([]*nativeType, error) { - nt, err := newNativeType(fieldNameHandler, rawType) - if err != nil { - return nil, err - } - result := []*nativeType{nt} - - alreadySeen := make(map[string]struct{}) - var iterateStructMembers func(reflect.Type) - iterateStructMembers = func(t reflect.Type) { - if t.Implements(reflect.TypeFor[ref.Val]()) { - // skip this field since it's a CEL ref.Val instance. - return - } - if k := t.Kind(); k == reflect.Pointer || k == reflect.Slice || k == reflect.Array || k == reflect.Map { - iterateStructMembers(t.Elem()) - return - } - if t.Kind() != reflect.Struct { - return - } - if _, seen := alreadySeen[t.String()]; seen { - return - } - alreadySeen[t.String()] = struct{}{} - nt, ntErr := newNativeType(fieldNameHandler, t) - if ntErr != nil { - err = ntErr - return - } - result = append(result, nt) - - for idx := 0; idx < t.NumField(); idx++ { - iterateStructMembers(t.Field(idx).Type) - } - } - iterateStructMembers(rawType) - - return result, err -} - -var ( - errDuplicatedFieldName = errors.New("field name already exists in struct") -) - -func newNativeType(fieldNameHandler NativeTypesFieldNameHandler, rawType reflect.Type) (*nativeType, error) { - refType := rawType - if refType.Kind() == reflect.Pointer { - refType = refType.Elem() - } - if !isValidObjectType(refType) { - return nil, fmt.Errorf("unsupported reflect.Type %v, must be reflect.Struct", rawType) - } - - // Since naming collisions can only happen with struct tag parsing, we only check for them if it is enabled. - if fieldNameHandler != nil { - fieldNames := make(map[string]struct{}) - - for idx := 0; idx < refType.NumField(); idx++ { - field := refType.Field(idx) - fieldName := toFieldName(fieldNameHandler, field) - if isSkippedFieldName(fieldName) { - continue - } - if _, found := fieldNames[fieldName]; found { - return nil, fmt.Errorf("invalid field name `%s` in struct `%s`: %w", fieldName, refType.Name(), errDuplicatedFieldName) - } else { - fieldNames[fieldName] = struct{}{} - } - } - } - - return &nativeType{ - typeName: fmt.Sprintf("%s.%s", simplePkgAlias(refType.PkgPath()), refType.Name()), - refType: refType, - fieldNameHandler: fieldNameHandler, - }, nil -} - -type nativeType struct { - typeName string - refType reflect.Type - fieldNameHandler NativeTypesFieldNameHandler -} - -// ConvertToNative implements ref.Val.ConvertToNative. -func (t *nativeType) ConvertToNative(typeDesc reflect.Type) (any, error) { - return nil, fmt.Errorf("type conversion error for type to '%v'", typeDesc) -} - -// ConvertToType implements ref.Val.ConvertToType. -func (t *nativeType) ConvertToType(typeVal ref.Type) ref.Val { - switch typeVal { - case types.TypeType: - return types.TypeType - } - return types.NewErr("type conversion error from '%s' to '%s'", types.TypeType, typeVal) -} - -// Equal returns true of both type names are equal to each other. -func (t *nativeType) Equal(other ref.Val) ref.Val { - otherType, ok := other.(ref.Type) - return types.Bool(ok && t.TypeName() == otherType.TypeName()) -} - -// HasTrait implements the ref.Type interface method. -func (t *nativeType) HasTrait(trait int) bool { - return nativeObjTraitMask&trait == trait -} - -// String implements the strings.Stringer interface method. -func (t *nativeType) String() string { - return t.typeName -} - -// Type implements the ref.Val interface method. -func (t *nativeType) Type() ref.Type { - return types.TypeType -} - -// TypeName implements the ref.Type interface method. -func (t *nativeType) TypeName() string { - return t.typeName -} - -// Value implements the ref.Val interface method. -func (t *nativeType) Value() any { - return t.typeName -} - -// fieldByName returns the corresponding reflect.StructField for the give name either by matching -// field tag or field name. -func (t *nativeType) fieldByName(fieldName string) (reflect.StructField, bool) { - if isSkippedFieldName(fieldName) { - return reflect.StructField{}, false - } - - if t.fieldNameHandler == nil { - return t.refType.FieldByName(fieldName) - } - - for i := 0; i < t.refType.NumField(); i++ { - f := t.refType.Field(i) - if toFieldName(t.fieldNameHandler, f) == fieldName { - return f, true - } - } - - return reflect.StructField{}, false -} - -// hasField returns whether a field name has a corresponding Golang reflect.StructField -func (t *nativeType) hasField(fieldName string) (reflect.StructField, bool) { - f, found := t.fieldByName(fieldName) - if !found || !f.IsExported() || !isSupportedType(f.Type) { - return reflect.StructField{}, false - } - return f, true -} - -func adaptFieldValue(adapter types.Adapter, refField reflect.Value) ref.Val { - return adapter.NativeToValue(getFieldValue(refField)) -} - -func getFieldValue(refField reflect.Value) any { - if refField.IsZero() { - switch refField.Kind() { - case reflect.Struct: - if refField.Type() == timestampType { - return time.Unix(0, 0) - } - case reflect.Pointer: - return reflect.New(refField.Type().Elem()).Interface() - } - } - return refField.Interface() -} - -func simplePkgAlias(pkgPath string) string { - paths := strings.Split(pkgPath, "/") - if len(paths) == 0 { - return "" - } - return paths[len(paths)-1] -} - -func isValidObjectType(refType reflect.Type) bool { - return refType.Kind() == reflect.Struct -} - -func isSupportedType(refType reflect.Type) bool { - switch refType.Kind() { - case reflect.Chan, reflect.Complex64, reflect.Complex128, reflect.Func, reflect.UnsafePointer, reflect.Uintptr: - return false - case reflect.Array, reflect.Slice: - return isSupportedType(refType.Elem()) - case reflect.Map: - return isSupportedType(refType.Key()) && isSupportedType(refType.Elem()) - } - return true -} - -var ( - pbMsgInterfaceType = reflect.TypeFor[protoreflect.ProtoMessage]() - refValType = reflect.TypeFor[ref.Val]() - timestampType = reflect.TypeFor[time.Time]() - durationType = reflect.TypeFor[time.Duration]() -) diff --git a/ext/native_test.go b/ext/native_test.go index 3a37194aa..31e8987a5 100644 --- a/ext/native_test.go +++ b/ext/native_test.go @@ -873,10 +873,19 @@ func TestNativeTypeConvertToType(t *testing.T) { for i, tst := range nativeTests { tc := tst t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { - handler := fieldNameByTag(tc.tag) - nt, err := newNativeType(handler, reflect.TypeOf(&TestAllTypes{})) + handler := func(f reflect.StructField) string { + tag, found := f.Tag.Lookup(tc.tag) + if found { + splits := strings.Split(tag, ",") + if len(splits) > 0 { + return splits[0] + } + } + return f.Name + } + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes](), types.ParseStructField(handler)) if err != nil { - t.Fatalf("newNativeType() failed: %v", err) + t.Fatalf("NewNativeType() failed: %v", err) } if nt.ConvertToType(types.TypeType) != types.TypeType { t.Error("ConvertToType(Type) failed") @@ -889,9 +898,9 @@ func TestNativeTypeConvertToType(t *testing.T) { } func TestNativeTypeConvertToNative(t *testing.T) { - nt, err := newNativeType(fieldNameByTag("cel"), reflect.TypeOf(&TestAllTypes{})) + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes]()) if err != nil { - t.Fatalf("newNativeType() failed: %v", err) + t.Fatalf("NewNativeType() failed: %v", err) } out, err := nt.ConvertToNative(reflect.TypeOf(1)) if err == nil { @@ -900,9 +909,9 @@ func TestNativeTypeConvertToNative(t *testing.T) { } func TestNativeTypeHasTrait(t *testing.T) { - nt, err := newNativeType(fieldNameByTag("cel"), reflect.TypeOf(&TestAllTypes{})) + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes]()) if err != nil { - t.Fatalf("newNativeType() failed: %v", err) + t.Fatalf("NewNativeType() failed: %v", err) } if !nt.HasTrait(traits.IndexerType) || !nt.HasTrait(traits.FieldTesterType) { t.Error("nt.HasTrait() failed indicate support for presence test and field access.") @@ -910,9 +919,9 @@ func TestNativeTypeHasTrait(t *testing.T) { } func TestNativeTypeValue(t *testing.T) { - nt, err := newNativeType(fieldNameByTag("cel"), reflect.TypeOf(&TestAllTypes{})) + nt, err := types.NewNativeType(reflect.TypeFor[*TestAllTypes]()) if err != nil { - t.Fatalf("newNativeType() failed: %v", err) + t.Fatalf("NewNativeType() failed: %v", err) } if nt.Value() != nt.String() { t.Errorf("nt.Value() got %v, wanted %v", nt.Value(), nt.String()) @@ -920,12 +929,25 @@ func TestNativeTypeValue(t *testing.T) { } func TestNativeStructWithMultipleSameFieldNames(t *testing.T) { - _, err := newNativeType(fieldNameByTag("cel"), reflect.TypeOf(TestStructWithMultipleSameNames{})) + tagHandler := func(f reflect.StructField) string { + tag, found := f.Tag.Lookup("cel") + if found { + splits := strings.Split(tag, ",") + if len(splits) > 0 { + return splits[0] + } + } + return f.Name + } + _, err := types.NewNativeType( + reflect.TypeFor[TestStructWithMultipleSameNames](), + types.ParseStructField(tagHandler), + ) if err == nil { - t.Fatal("newNativeType() did not fail as expected") + t.Fatal("NewNativeType() did not fail as expected") } - if !errors.Is(err, errDuplicatedFieldName) { - t.Fatalf("newNativeType() exepected duplicated field name error, but got: %v", err) + if !strings.Contains(err.Error(), "field name already exists") { + t.Fatalf("NewNativeType() expected duplicated field name error, but got: %v", err) } } @@ -965,6 +987,15 @@ func TestNativeStructEmbedded(t *testing.T) { }, out: true, }, + { + expr: `test.Name == "name"`, + in: map[string]any{ + "test": &TestEmbeddedTypes{ + Custom: Custom{Name: "name"}, + }, + }, + out: true, + }, } envOpts := []cel.EnvOption{ @@ -1015,6 +1046,79 @@ func TestNativeStructEmbedded(t *testing.T) { } } +func TestNativeStructEmbeddedPointer(t *testing.T) { + nativeTests := []struct { + expr string + in map[string]any + out any + }{ + { + expr: `!has(test.custom_name) && test.custom_name == ""`, + in: map[string]any{ + "test": &TestEmbeddedPointerTypes{ + TestNestedType: nil, + }, + }, + out: true, + }, + { + expr: `has(test.custom_name) && test.custom_name == "name"`, + in: map[string]any{ + "test": &TestEmbeddedPointerTypes{ + TestNestedType: &TestNestedType{NestedCustomName: "name"}, + }, + }, + out: true, + }, + { + expr: `ext.TestEmbeddedPointerTypes{custom_name: "name"}.custom_name == "name"`, + in: nil, + out: true, + }, + } + + envOpts := []cel.EnvOption{ + NativeTypes( + reflect.TypeFor[*TestEmbeddedPointerTypes](), + reflect.TypeFor[*TestNestedType](), + ParseStructTag("json"), + ), + cel.Variable("test", cel.ObjectType("ext.TestEmbeddedPointerTypes")), + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv(NativeTypes()) failed: %v", err) + } + + for i, tst := range nativeTests { + tc := tst + t.Run(fmt.Sprintf("[%d]", i), func(t *testing.T) { + pAst, iss := env.Parse(tc.expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%v) failed: %v", tc.expr, iss.Err()) + } + cAst, iss := env.Check(pAst) + if iss.Err() != nil { + t.Fatalf("env.Check(%v) failed: %v", tc.expr, iss.Err()) + } + for _, ast := range []*cel.Ast{pAst, cAst} { + prg, err := env.Program(ast) + if err != nil { + t.Fatal(err) + } + out, _, err := prg.Eval(tc.in) + if err != nil { + t.Fatalf("prg.Eval() failed: %v", err) + } + if !reflect.DeepEqual(out.Value(), tc.out) { + t.Errorf("got %v, wanted %v for expr: %s", out.Value(), tc.out, tc.expr) + } + } + }) + } +} + func TestNativeStructHiddenField(t *testing.T) { envOpts := []cel.EnvOption{ NativeTypes( @@ -1168,7 +1272,7 @@ func TestTypeResolutionRace(t *testing.T) { } // testEnv initializes the test environment common to all tests. -func testNativeEnv(t *testing.T, opts ...any) *cel.Env { +func testNativeEnv(t testing.TB, opts ...any) *cel.Env { t.Helper() envOpts := []cel.EnvOption{ cel.Container("ext"), @@ -1217,8 +1321,8 @@ type Custom struct { } type TestStructWithMultipleSameNames struct { - Name string - custom_name string `cel:"Name"` + Name string + CustomName string `cel:"Name"` } type TestNestedType struct { @@ -1268,12 +1372,243 @@ type TestMapVal struct { } type TestEmbeddedTypes struct { + Custom TestNestedType `json:"embedded,omitempty"` Skipped string `json:"-"` } +type TestEmbeddedPointerTypes struct { + *TestNestedType `json:"embedded,omitempty"` +} + type TestRefValFieldType struct { OptionalName *types.Optional `cel:"optional_name"` IntVal types.Int CELTime types.Timestamp `cel:"time"` } + +// registeredNativeStruct is registered with NativeTypes in the delegation test. +type registeredNativeStruct struct { + Name string +} + +// unregisteredNativeStruct is not registered, so NativeToValue should hand it to +// the composed base adapter rather than wrapping it as a native object. +type unregisteredNativeStruct struct { + Name string +} + +// recordingAdapter converts unregisteredNativeStruct into a sentinel string and +// records that it was asked to, so the test can confirm nativeTypeProvider +// delegated the value. Everything else falls through to the base adapter. +type recordingAdapter struct { + base types.Adapter + saw bool +} + +func (a *recordingAdapter) NativeToValue(value any) ref.Val { + if _, ok := value.(unregisteredNativeStruct); ok { + a.saw = true + return types.String("from-base-adapter") + } + return a.base.NativeToValue(value) +} + +func TestNativeToValueDelegatesUnregisteredStructs(t *testing.T) { + custom := &recordingAdapter{base: types.DefaultTypeAdapter} + env, err := cel.NewEnv( + cel.CustomTypeAdapter(custom), + NativeTypes(reflect.TypeOf(registeredNativeStruct{})), + ) + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + adapter := env.CELTypeAdapter() + + // An unregistered struct must reach the composed base adapter. + got := adapter.NativeToValue(unregisteredNativeStruct{Name: "x"}) + if !custom.saw { + t.Error("base adapter was not consulted for an unregistered struct") + } + if got.Equal(types.String("from-base-adapter")) != types.True { + t.Errorf("NativeToValue(unregisteredNativeStruct) = %v, want the base adapter's value", got) + } + + // A registered native type must still be wrapped as a native object. + custom.saw = false + gotReg := adapter.NativeToValue(registeredNativeStruct{Name: "y"}) + if custom.saw { + t.Error("base adapter was consulted for a registered native type") + } + if tn := gotReg.Type().TypeName(); !strings.Contains(tn, "registeredNativeStruct") { + t.Errorf("NativeToValue(registeredNativeStruct).Type() = %q, want a native object type", tn) + } +} + +func BenchmarkNativeTypesEval(b *testing.B) { + benchmarks := []struct { + name string + expr string + in any + envOpts []any + }{ + { + name: "FieldAccess", + expr: "t.Int32Val + t.Int64Val", + in: map[string]any{ + "t": &TestAllTypes{Int32Val: 10, Int64Val: 20}, + }, + }, + { + name: "NestedFieldAccess", + expr: "t.NestedVal.NestedCustomName == 'name'", + in: map[string]any{ + "t": &TestAllTypes{ + NestedVal: &TestNestedType{NestedCustomName: "name"}, + }, + }, + }, + { + name: "StructCreation", + expr: `ext.TestAllTypes{ + BoolVal: true, + Int32Val: 10, + Int64Val: 20, + StringVal: 'hello world', + }`, + }, + { + name: "FieldPresence", + expr: "has(t.BoolVal) && has(t.NestedVal)", + in: map[string]any{ + "t": &TestAllTypes{ + BoolVal: true, + NestedVal: &TestNestedType{}, + }, + }, + }, + { + name: "StructTagFieldAccess", + expr: "t.custom_name == 'name'", + envOpts: []any{ParseStructTags(true)}, + in: map[string]any{ + "t": &TestAllTypes{CustomName: "name"}, + }, + }, + { + name: "ListExists", + expr: "tests.exists(t, t.Int32Val > 15)", + in: map[string]any{ + "tests": []*TestAllTypes{ + {Int32Val: 10}, + {Int32Val: 20}, + }, + }, + }, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + envOpts := append([]any{ + cel.Variable("t", cel.ObjectType("ext.TestAllTypes")), + }, bm.envOpts...) + env := testNativeEnv(b, envOpts...) + ast, iss := env.Compile(bm.expr) + if iss.Err() != nil { + b.Fatalf("env.Compile(%q) failed: %v", bm.expr, iss.Err()) + } + prg, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize)) + if err != nil { + b.Fatalf("env.Program() failed: %v", err) + } + input := bm.in + if input == nil { + input = cel.NoVars() + } + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + prg.Eval(input) + } + }) + } +} + +func BenchmarkNativeToValue(b *testing.B) { + env := testNativeEnv(b) + adapter := env.CELTypeAdapter() + + nested := &TestNestedType{ + NestedListVal: []string{"a", "b", "c"}, + NestedMapVal: map[int64]bool{1: true}, + NestedCustomName: "test", + } + allTypes := &TestAllTypes{ + BoolVal: true, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + NestedVal: nested, + ListVal: []*TestNestedType{nested}, + } + allTypesSlice := []*TestAllTypes{allTypes, allTypes} + + benchmarks := []struct { + name string + val any + }{ + {name: "TestNestedType", val: nested}, + {name: "TestAllTypes", val: allTypes}, + {name: "SliceTestAllTypes", val: allTypesSlice}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + adapter.NativeToValue(bm.val) + } + }) + } +} + +func BenchmarkConvertToNative(b *testing.B) { + env := testNativeEnv(b) + adapter := env.CELTypeAdapter() + + allTypes := &TestAllTypes{ + BoolVal: true, + Int32Val: 10, + Int64Val: 20, + StringVal: "hello world", + } + celVal := adapter.NativeToValue(allTypes) + targetType := reflect.TypeOf(&TestAllTypes{}) + + allTypesSlice := []*TestAllTypes{allTypes, allTypes} + celSliceVal := adapter.NativeToValue(allTypesSlice) + sliceTargetType := reflect.TypeOf([]*TestAllTypes{}) + + b.Run("TestAllTypes", func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := celVal.ConvertToNative(targetType) + if err != nil { + b.Fatalf("ConvertToNative failed: %v", err) + } + } + }) + + b.Run("SliceTestAllTypes", func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := celSliceVal.ConvertToNative(sliceTargetType) + if err != nil { + b.Fatalf("ConvertToNative failed: %v", err) + } + } + }) +} diff --git a/ext/network.go b/ext/network.go index e1177d70b..32f36c8c7 100644 --- a/ext/network.go +++ b/ext/network.go @@ -23,6 +23,7 @@ import ( "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/checker" "github.com/authzed/cel-go/common/ast" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" "github.com/authzed/cel-go/interpreter" @@ -766,13 +767,13 @@ func estimateNetworkContainsCIDRStringCost(estimator checker.CostEstimator, targ // Runtime cost tracking functions for network extensions. func trackNetworkParseCost(args []ref.Val, result ref.Val) *uint64 { - cost := uint64(math.Ceil(float64(actualSize(args[0])) * stringCostFactor)) - return &cost + total := cost.SafeMultiplyByFactor(actualSize(args[0]), stringCostFactor) + return &total } func trackIPIsCanonicalCost(args []ref.Val, result ref.Val) *uint64 { - cost := uint64(math.Ceil(float64(actualSize(args[0])) * 2 * stringCostFactor)) - return &cost + total := cost.SafeMultiplyByFactor(actualSize(args[0]), 2*stringCostFactor) + return &total } func trackNetworkNominalCost(args []ref.Val, result ref.Val) *uint64 { @@ -781,30 +782,30 @@ func trackNetworkNominalCost(args []ref.Val, result ref.Val) *uint64 { func trackNetworkContainsIPIPCost(args []ref.Val, result ref.Val) *uint64 { cidrSize := actualSize(args[0]) - cost := uint64(math.Ceil(float64(cidrSize+cidrSize) * stringCostFactor)) - return &cost + total := cost.SafeMultiplyByFactor(cost.SafeAdd(cidrSize, cidrSize), stringCostFactor) + return &total } func trackNetworkContainsIPStringCost(args []ref.Val, result ref.Val) *uint64 { cidrSize := actualSize(args[0]) otherSize := actualSize(args[1]) - cost := uint64(math.Ceil(float64(cidrSize+cidrSize) * stringCostFactor)) - cost = safeAdd(cost, uint64(math.Ceil(float64(otherSize)*stringCostFactor))) - return &cost + total := cost.SafeMultiplyByFactor(cost.SafeAdd(cidrSize, cidrSize), stringCostFactor) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(otherSize, stringCostFactor)) + return &total } func trackNetworkContainsCIDRCIDRCost(args []ref.Val, result ref.Val) *uint64 { cidrSize := actualSize(args[0]) - cost := uint64(math.Ceil(float64(cidrSize+cidrSize) * stringCostFactor)) - cost = safeAdd(cost, uint64(math.Ceil(float64(cidrSize)*stringCostFactor)), 1) - return &cost + total := cost.SafeMultiplyByFactor(cost.SafeAdd(cidrSize, cidrSize), stringCostFactor) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(cidrSize, stringCostFactor), 1) + return &total } func trackNetworkContainsCIDRStringCost(args []ref.Val, result ref.Val) *uint64 { cidrSize := actualSize(args[0]) otherSize := actualSize(args[1]) - cost := uint64(math.Ceil(float64(cidrSize+cidrSize) * stringCostFactor)) - cost = safeAdd(cost, uint64(math.Ceil(float64(cidrSize)*stringCostFactor)), 1) - cost = safeAdd(cost, uint64(math.Ceil(float64(otherSize)*stringCostFactor))) - return &cost + total := cost.SafeMultiplyByFactor(cost.SafeAdd(cidrSize, cidrSize), stringCostFactor) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(cidrSize, stringCostFactor), 1) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(otherSize, stringCostFactor)) + return &total } diff --git a/ext/regex.go b/ext/regex.go index fb0c2a70a..ec3c2a755 100644 --- a/ext/regex.go +++ b/ext/regex.go @@ -25,6 +25,7 @@ import ( "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/checker" "github.com/authzed/cel-go/common" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" "github.com/authzed/cel-go/interpreter" @@ -411,8 +412,8 @@ func estimateReplaceCost() checker.FunctionEstimator { func extractCostTracker() interpreter.FunctionTracker { return func(args []ref.Val, result ref.Val) *uint64 { - targetCost := float64(safeAdd(actualSize(args[0]), 1)) * common.StringTraversalCostFactor - regexCost := float64(safeAdd(actualSize(args[1]), 1)) * common.RegexStringLengthCostFactor + targetCost := float64(cost.SafeAdd(actualSize(args[0]), 1)) * common.StringTraversalCostFactor + regexCost := float64(cost.SafeAdd(actualSize(args[1]), 1)) * common.RegexStringLengthCostFactor // Actual search cost calculation = targetCost + regexCost searchCost := targetCost * regexCost // The total cost is the base call cost + search cost + result string allocation. diff --git a/ext/regex_test.go b/ext/regex_test.go index cd0722bd2..7429f9f2b 100644 --- a/ext/regex_test.go +++ b/ext/regex_test.go @@ -415,3 +415,77 @@ func TestRegexCosts(t *testing.T) { }) } } + +func TestRegexProgramSizeLimit(t *testing.T) { + overloads := []struct { + name string + expr string + }{ + { + name: "matches", + expr: `'a1'.matches(pat)`, + }, + { + name: "regex.extract", + expr: `regex.extract('a1', pat)`, + }, + { + name: "regex.extractAll", + expr: `regex.extractAll('a1', pat)`, + }, + { + name: "regex.replace 3-arg", + expr: `regex.replace('a1', pat, 'x')`, + }, + { + name: "regex.replace 4-arg", + expr: `regex.replace('a1', pat, 'x', 1)`, + }, + } + + t.Run("ExceedsLimit", func(t *testing.T) { + for _, tc := range overloads { + t.Run(tc.name, func(t *testing.T) { + prg, err := cel.Compile(tc.expr, + cel.OptionalTypes(), + Regex(), + cel.RegexProgramSizeLimit(5), + cel.Variable("pat", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.Compile(%s) failed: %v", tc.expr, err) + } + _, _, err = prg.Eval(map[string]any{"pat": "(a|b)*[0-9]+"}) + if err == nil { + t.Fatalf("expected runtime error for regex program size exceeding limit") + } + if !strings.Contains(err.Error(), "regex program size 8 exceeds limit of 5") { + t.Fatalf("got error %v, expected error containing 'regex program size 8 exceeds limit of 5'", err) + } + }) + } + }) + + t.Run("WithinLimit", func(t *testing.T) { + for _, tc := range overloads { + t.Run(tc.name, func(t *testing.T) { + prg, err := cel.Compile(tc.expr, + cel.OptionalTypes(), + Regex(), + cel.RegexProgramSizeLimit(10), + cel.Variable("pat", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.Compile(%s) failed: %v", tc.expr, err) + } + val, _, err := prg.Eval(map[string]any{"pat": "(a|b)*[0-9]+"}) + if err != nil { + t.Fatalf("prg.Eval(%s) unexpected error: %v", tc.expr, err) + } + if val == nil { + t.Fatalf("prg.Eval(%s) returned nil result", tc.expr) + } + }) + } + }) +} diff --git a/ext/security/go.mod b/ext/security/go.mod new file mode 100644 index 000000000..e94d07cf6 --- /dev/null +++ b/ext/security/go.mod @@ -0,0 +1,18 @@ +module github.com/authzed/cel-go/ext/security + +go 1.23.0 + +require github.com/authzed/cel-go v0.31.0 + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) + +replace github.com/authzed/cel-go => ../../ diff --git a/ext/security/go.sum b/ext/security/go.sum new file mode 100644 index 000000000..94447aa14 --- /dev/null +++ b/ext/security/go.sum @@ -0,0 +1,20 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/ext/security/hmac/BUILD.bazel b/ext/security/hmac/BUILD.bazel new file mode 100644 index 000000000..f323594b1 --- /dev/null +++ b/ext/security/hmac/BUILD.bazel @@ -0,0 +1,34 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +package( + default_visibility = ["//visibility:public"], + licenses = ["notice"], # Apache 2.0 +) + +go_library( + name = "go_default_library", + srcs = [ + "hmac.go", + ], + importpath = "github.com/authzed/cel-go/ext/security/hmac", + deps = [ + "//cel:go_default_library", + "//common/types:go_default_library", + "//common/types/ref:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = [ + "hmac_test.go", + ], + embed = [ + ":go_default_library", + ], + deps = [ + "//cel:go_default_library", + "//ext:go_default_library", + ], +) diff --git a/ext/security/hmac/hmac.go b/ext/security/hmac/hmac.go new file mode 100644 index 000000000..ee24c2d64 --- /dev/null +++ b/ext/security/hmac/hmac.go @@ -0,0 +1,359 @@ +// 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 +// +// 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 hmac implements CEL extension functions for Hash-based Message Authentication Code (HMAC) verification and computation. +package hmac + +import ( + "crypto" + "crypto/hmac" + _ "crypto/md5" + _ "crypto/sha1" + _ "crypto/sha256" + _ "crypto/sha512" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/authzed/cel-go/cel" + "github.com/authzed/cel-go/common/types" + "github.com/authzed/cel-go/common/types/ref" +) + +// Library returns a cel.EnvOption to configure extended functions for HMAC signature verification and computation. +func Library(options ...Option) cel.EnvOption { + l := &hmacLib{ + version: ^uint32(0), + customAlgorithms: make(map[string]crypto.Hash), + } + for _, o := range options { + l = o(l) + } + if len(l.customAlgorithms) == 0 { + l = CommonAlgorithms()(l) + } + return cel.Lib(l) +} + +// Option declares a functional operator for configuring HMAC extension library behavior. +type Option func(*hmacLib) *hmacLib + +// Version sets the library version for HMAC extensions. +func Version(version uint32) Option { + return func(l *hmacLib) *hmacLib { + l.version = version + return l + } +} + +// MaxPrefixLength sets the maximum signature prefix length to parse during verification. +// Defaults to 20. +func MaxPrefixLength(limit int) Option { + return func(l *hmacLib) *hmacLib { + l.maxPrefixLength = limit + return l + } +} + +// Algorithm registers a crypto.Hash algorithm with optional aliases +// (e.g. Algorithm(crypto.SHA256, "HS256")), +// exposing constant declarations (e.g., hmac.SHA256, hmac.HS256) in CEL and enabling it for HMAC operations. +func Algorithm(h crypto.Hash, aliases ...string) Option { + return func(l *hmacLib) *hmacLib { + if l.customAlgorithms == nil { + l.customAlgorithms = make(map[string]crypto.Hash) + } + name := h.String() + normName := normalizeAlgName(name) + l.customAlgorithms[normName] = h + l.customAlgorithms[name] = h + for _, alias := range aliases { + l.customAlgorithms[normalizeAlgName(alias)] = h + l.customAlgorithms[alias] = h + } + + if normName != "" { + l.addConstant("hmac."+normName, normName) + } + for _, alias := range aliases { + constAlias := normalizeAlgName(alias) + if constAlias != "" { + l.addConstant("hmac."+constAlias, normName) + } + } + + return l + } +} + +// CommonAlgorithms registers the most common HMAC hash algorithms (SHA256, SHA384, SHA512, SHA224, SHA512/256, SHA512/224) +// along with their JOSE/JWT aliases (HS256, HS384, HS512, HS224, HS512/256, HS512/224) using Algorithm options by proxy. +func CommonAlgorithms() Option { + return func(l *hmacLib) *hmacLib { + opts := []Option{ + Algorithm(crypto.SHA256, "HS256"), + Algorithm(crypto.SHA384, "HS384"), + Algorithm(crypto.SHA512, "HS512"), + Algorithm(crypto.SHA224, "HS224"), + Algorithm(crypto.SHA512_256, "HS512_256"), + Algorithm(crypto.SHA512_224, "HS512_224"), + } + for _, opt := range opts { + l = opt(l) + } + return l + } +} + +type celConstant struct { + name string + val string +} + +type hmacLib struct { + version uint32 + maxPrefixLength int + customAlgorithms map[string]crypto.Hash + constants []celConstant +} + +func (l *hmacLib) addConstant(name, val string) { + for _, c := range l.constants { + if c.name == name { + return + } + } + l.constants = append(l.constants, celConstant{name: name, val: val}) +} + +// LibraryName returns the CEL library identifier string. +func (*hmacLib) LibraryName() string { + return "cel.lib.ext.security.hmac" +} + +// CompileOptions returns environment options for declaring CEL functions and constants. +func (l *hmacLib) CompileOptions() []cel.EnvOption { + var opts []cel.EnvOption + + for _, c := range l.constants { + opts = append(opts, cel.Constant(c.name, cel.StringType, types.String(c.val))) + } + + opts = append(opts, + cel.Function("hmac.verify", + cel.Overload("hmac_verify_bytes_bytes_bytes_string", + []*cel.Type{cel.BytesType, cel.BytesType, cel.BytesType, cel.StringType}, + cel.BoolType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + msg := args[0].(types.Bytes) + sig := args[1].(types.Bytes) + secret := args[2].(types.Bytes) + alg := args[3].(types.String) + return types.Bool(l.verifyBytes(msg, sig, secret, string(alg))) + }), + ), + cel.Overload("hmac_verify_string_string_string_string", + []*cel.Type{cel.StringType, cel.StringType, cel.StringType, cel.StringType}, + cel.BoolType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + msg := args[0].(types.String) + sig := args[1].(types.String) + secret := args[2].(types.String) + alg := args[3].(types.String) + return types.Bool(l.verifyString(string(msg), string(sig), string(secret), string(alg))) + }), + ), + ), + + cel.Function("hmac.compute", + cel.Overload("hmac_compute_bytes_bytes_string", + []*cel.Type{cel.BytesType, cel.BytesType, cel.StringType}, + cel.BytesType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + msg := args[0].(types.Bytes) + secret := args[1].(types.Bytes) + alg := args[2].(types.String) + mac, err := l.compute(msg, secret, string(alg)) + if err != nil { + return types.ValOrErr(args[0], "%v", err) + } + return types.Bytes(mac) + }), + ), + cel.Overload("hmac_compute_string_string_string", + []*cel.Type{cel.StringType, cel.StringType, cel.StringType}, + cel.BytesType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + msg := args[0].(types.String) + secret := args[1].(types.String) + alg := args[2].(types.String) + mac, err := l.compute([]byte(string(msg)), []byte(string(secret)), string(alg)) + if err != nil { + return types.ValOrErr(args[0], "%v", err) + } + return types.Bytes(mac) + }), + ), + ), + ) + + return opts +} + +// ProgramOptions returns program options for HMAC extensions. +func (l *hmacLib) ProgramOptions() []cel.ProgramOption { + return nil +} + +func (l *hmacLib) compute(msg, secret []byte, alg string) ([]byte, error) { + hType, err := l.resolveHash(alg) + if err != nil { + return nil, err + } + return computeHMAC(msg, secret, hType) +} + +func (l *hmacLib) verifyBytes(msg, sig, secret []byte, alg string) bool { + hType, err := l.resolveHash(alg) + if err != nil { + return false + } + expectedMAC, err := computeHMAC(msg, secret, hType) + if err != nil { + return false + } + return hmac.Equal(expectedMAC, sig) +} + +func (l *hmacLib) verifyString(msgStr, sigStr, secretStr, alg string) bool { + sigStr = strings.TrimSpace(sigStr) + detectedAlg, cleanSig := l.parseSignaturePrefix(sigStr) + effectiveAlg := alg + if detectedAlg != "" { + effectiveAlg = detectedAlg + } + + hType, err := l.resolveHash(effectiveAlg) + if err != nil { + return false + } + + expectedMAC, err := computeHMAC([]byte(msgStr), []byte(secretStr), hType) + if err != nil { + return false + } + + // Try hex decoding + if hexBytes, err := hex.DecodeString(cleanSig); err == nil && len(hexBytes) == len(expectedMAC) { + if hmac.Equal(expectedMAC, hexBytes) { + return true + } + } + + // Try base64 standard decoding + if b64Bytes, err := decodeBase64StdSegment(cleanSig); err == nil && len(b64Bytes) == len(expectedMAC) { + if hmac.Equal(expectedMAC, b64Bytes) { + return true + } + } + + // Try base64 URL decoding + if b64URLBytes, err := decodeBase64URLSegment(cleanSig); err == nil && len(b64URLBytes) == len(expectedMAC) { + if hmac.Equal(expectedMAC, b64URLBytes) { + return true + } + } + + // Fallback raw string comparison + return hmac.Equal(expectedMAC, []byte(cleanSig)) +} + +func (l *hmacLib) parseSignaturePrefix(sig string) (string, string) { + sig = strings.TrimSpace(sig) + limit := l.maxPrefixLength + if limit <= 0 { + limit = 20 + } + if idx := strings.Index(sig, "="); idx > 0 && idx < limit { + prefix := strings.TrimSpace(sig[:idx]) + rest := strings.TrimSpace(sig[idx+1:]) + + normPrefix := normalizeAlgName(prefix) + if _, ok := l.customAlgorithms[normPrefix]; ok { + for name := range l.customAlgorithms { + if normalizeAlgName(name) == normPrefix { + return name, rest + } + } + } + if _, ok := l.customAlgorithms[prefix]; ok { + return prefix, rest + } + + if strings.EqualFold(prefix, "v1") || strings.EqualFold(prefix, "v0") { + return "", rest + } + } + return "", sig +} + +func normalizeAlgName(alg string) string { + s := strings.TrimSpace(alg) + s = strings.ReplaceAll(s, "-", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ToUpper(s) + if after, ok := strings.CutPrefix(s, "SHA_"); ok { + s = "SHA" + after + } + return s +} + +func (l *hmacLib) resolveHash(alg string) (crypto.Hash, error) { + norm := normalizeAlgName(alg) + for name, h := range l.customAlgorithms { + if strings.EqualFold(alg, name) || norm == normalizeAlgName(name) { + return h, nil + } + } + + return 0, fmt.Errorf("unsupported HMAC hash algorithm: %q", alg) +} + +func computeHMAC(msg, secret []byte, hType crypto.Hash) ([]byte, error) { + if !hType.Available() { + return nil, fmt.Errorf("hash algorithm %v is not available", hType) + } + mac := hmac.New(hType.New, secret) + mac.Write(msg) + return mac.Sum(nil), nil +} + +// decodeBase64URLSegment decodes a URL-safe base64 string with or without padding. +func decodeBase64URLSegment(seg string) ([]byte, error) { + seg = strings.TrimSpace(seg) + if data, err := base64.RawURLEncoding.DecodeString(seg); err == nil { + return data, nil + } + return base64.URLEncoding.DecodeString(seg) +} + +// decodeBase64StdSegment decodes a standard base64 string with or without padding. +func decodeBase64StdSegment(seg string) ([]byte, error) { + seg = strings.TrimSpace(seg) + if data, err := base64.RawStdEncoding.DecodeString(seg); err == nil { + return data, nil + } + return base64.StdEncoding.DecodeString(seg) +} diff --git a/ext/security/hmac/hmac_test.go b/ext/security/hmac/hmac_test.go new file mode 100644 index 000000000..a0dd74ad3 --- /dev/null +++ b/ext/security/hmac/hmac_test.go @@ -0,0 +1,607 @@ +// 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 +// +// 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 hmac_test + +import ( + "crypto" + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "reflect" + "testing" + + "github.com/authzed/cel-go/cel" + "github.com/authzed/cel-go/ext" + hmaclib "github.com/authzed/cel-go/ext/security/hmac" +) + +func evalExpr(t *testing.T, env *cel.Env, expr string, vars map[string]any) any { + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + t.Fatalf("Compile(%q) failed: %v", expr, issues.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("Program(%q) failed: %v", expr, err) + } + val, _, err := prg.Eval(vars) + if err != nil { + t.Fatalf("Eval(%q) failed: %v", expr, err) + } + return val.Value() +} + +func TestHMACUniformSignaturesAndConstants(t *testing.T) { + secretStr := "my-shared-secret-key" + secretBytes := []byte(secretStr) + msgStr := `{"action":"push","ref":"refs/heads/main"}` + msgBytes := []byte(msgStr) + + // Compute expected SHA256 MAC + h256 := hmac.New(sha256.New, secretBytes) + h256.Write(msgBytes) + mac256Bytes := h256.Sum(nil) + mac256Hex := hex.EncodeToString(mac256Bytes) + mac256B64 := base64.StdEncoding.EncodeToString(mac256Bytes) + mac256B64URL := base64.RawURLEncoding.EncodeToString(mac256Bytes) + + // Compute expected SHA512 MAC + h512 := hmac.New(sha512.New, secretBytes) + h512.Write(msgBytes) + mac512Bytes := h512.Sum(nil) + mac512Hex := hex.EncodeToString(mac512Bytes) + + env, err := cel.NewEnv( + hmaclib.Library(), + cel.Variable("msgStr", cel.StringType), + cel.Variable("msgBytes", cel.BytesType), + cel.Variable("secretStr", cel.StringType), + cel.Variable("secretBytes", cel.BytesType), + cel.Variable("sigHex", cel.StringType), + cel.Variable("sigB64", cel.StringType), + cel.Variable("sigB64URL", cel.StringType), + cel.Variable("sigBytes", cel.BytesType), + cel.Variable("sigGitHub", cel.StringType), + cel.Variable("sigStripe", cel.StringType), + cel.Variable("sig512Hex", cel.StringType), + cel.Variable("sig512Bytes", cel.BytesType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "msgStr": msgStr, + "msgBytes": msgBytes, + "secretStr": secretStr, + "secretBytes": secretBytes, + "sigHex": mac256Hex, + "sigB64": mac256B64, + "sigB64URL": mac256B64URL, + "sigBytes": mac256Bytes, + "sigGitHub": "sha256=" + mac256Hex, + "sigStripe": "v1=" + mac256Hex, + "sig512Hex": mac512Hex, + "sig512Bytes": mac512Bytes, + } + + tests := []struct { + name string + expr string + want any + }{ + // Uniform all-bytes verify + { + name: "verify_all_bytes_sha256", + expr: `hmac.verify(msgBytes, sigBytes, secretBytes, hmac.SHA256)`, + want: true, + }, + { + name: "verify_all_bytes_sha512", + expr: `hmac.verify(msgBytes, sig512Bytes, secretBytes, hmac.SHA512)`, + want: true, + }, + { + name: "verify_all_bytes_mismatch_sig", + expr: `hmac.verify(msgBytes, sig512Bytes, secretBytes, hmac.SHA256)`, + want: false, + }, + + // Uniform all-strings verify + { + name: "verify_all_strings_hex", + expr: `hmac.verify(msgStr, sigHex, secretStr, hmac.SHA256)`, + want: true, + }, + { + name: "verify_all_strings_b64", + expr: `hmac.verify(msgStr, sigB64, secretStr, hmac.SHA256)`, + want: true, + }, + { + name: "verify_all_strings_b64url", + expr: `hmac.verify(msgStr, sigB64URL, secretStr, hmac.SHA256)`, + want: true, + }, + { + name: "verify_all_strings_github_prefixed", + expr: `hmac.verify(msgStr, sigGitHub, secretStr, hmac.SHA256)`, + want: true, + }, + { + name: "verify_all_strings_stripe_prefixed", + expr: `hmac.verify(msgStr, sigStripe, secretStr, hmac.SHA256)`, + want: true, + }, + { + name: "verify_all_strings_sha512", + expr: `hmac.verify(msgStr, sig512Hex, secretStr, hmac.SHA512)`, + want: true, + }, + { + name: "verify_all_strings_string_literal_alg", + expr: `hmac.verify(msgStr, sigHex, secretStr, 'SHA256')`, + want: true, + }, + + // Uniform compute (returning bytes) + { + name: "compute_bytes_bytes_sha256", + expr: `hmac.compute(msgBytes, secretBytes, hmac.SHA256) == sigBytes`, + want: true, + }, + { + name: "compute_string_string_sha256", + expr: `hmac.compute(msgStr, secretStr, hmac.SHA256) == sigBytes`, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v (%T), want %v (%T)", tc.expr, got, got, tc.want, tc.want) + } + }) + } +} + +func TestAlgorithmConstants(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library(), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + tests := []struct { + expr string + want string + }{ + {`hmac.SHA256`, "SHA256"}, + {`hmac.SHA384`, "SHA384"}, + {`hmac.SHA512`, "SHA512"}, + {`hmac.SHA224`, "SHA224"}, + {`hmac.SHA512_256`, "SHA512_256"}, + {`hmac.SHA512_224`, "SHA512_224"}, + } + + for _, tc := range tests { + t.Run(tc.expr, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, nil) + if got != tc.want { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestHMACCompositionWithEncodersAndStrings(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library(), + ext.Encoders(), + ext.Strings(), + cel.Variable("msg", cel.StringType), + cel.Variable("secret", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "msg": "hello world", + "secret": "key", + } + + resBytes := evalExpr(t, env, `hmac.compute(msg, secret, hmac.SHA256)`, vars).([]byte) + expectedHex := hex.EncodeToString(resBytes) + expectedB64 := base64.StdEncoding.EncodeToString(resBytes) + + tests := []struct { + name string + expr string + want any + }{ + { + name: "format_hex", + expr: `"%x".format([hmac.compute(msg, secret, hmac.SHA256)])`, + want: expectedHex, + }, + { + name: "base64_encode", + expr: `base64.encode(hmac.compute(msg, secret, hmac.SHA256))`, + want: expectedB64, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v (%T), want %v (%T)", tc.expr, got, got, tc.want, tc.want) + } + }) + } +} + +func TestHMACAllAlgorithmsAndPrefixes(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library(hmaclib.Version(1)), + cel.Variable("msgStr", cel.StringType), + cel.Variable("secretStr", cel.StringType), + cel.Variable("sig", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + msg := "test-message" + secret := "secret-key" + vars := map[string]any{ + "msgStr": msg, + "secretStr": secret, + } + + algTests := []struct { + alg string + }{ + {"SHA256"}, + {"SHA384"}, + {"SHA512"}, + {"SHA224"}, + {"SHA512/256"}, + {"SHA512/224"}, + {"HS256"}, + {"HS384"}, + {"HS512"}, + {"HS224"}, + {"HS512/256"}, + {"HS512/224"}, + } + + for _, tc := range algTests { + t.Run("compute_"+tc.alg, func(t *testing.T) { + mac := evalExpr(t, env, `hmac.compute(msgStr, secretStr, '`+tc.alg+`')`, vars) + if len(mac.([]byte)) == 0 { + t.Errorf("empty mac for alg %q", tc.alg) + } + }) + } + + prefixTests := []struct { + prefix string + alg string + }{ + {"sha256=", "SHA256"}, + {"sha-256=", "SHA256"}, + {"hs256=", "SHA256"}, + {"sha384=", "SHA384"}, + {"sha-384=", "SHA384"}, + {"hs384=", "SHA384"}, + {"sha512=", "SHA512"}, + {"sha-512=", "SHA512"}, + {"hs512=", "SHA512"}, + {"v0=", "SHA256"}, + {"v1=", "SHA256"}, + } + + for _, tc := range prefixTests { + t.Run("prefix_"+tc.prefix, func(t *testing.T) { + macBytes := evalExpr(t, env, `hmac.compute(msgStr, secretStr, '`+tc.alg+`')`, vars).([]byte) + sigStr := tc.prefix + hex.EncodeToString(macBytes) + got := evalExpr(t, env, `hmac.verify(msgStr, '`+sigStr+`', secretStr, '`+tc.alg+`')`, vars) + if got != true { + t.Errorf("verify failed for prefix %q: got %v", tc.prefix, got) + } + }) + } + + rawSig := string(evalExpr(t, env, `hmac.compute(msgStr, secretStr, hmac.SHA256)`, vars).([]byte)) + rawVars := map[string]any{"msgStr": msg, "secretStr": secret, "sig": rawSig} + + invalidTests := []struct { + name string + expr string + vars map[string]any + want any + }{ + { + name: "verify_raw_string", + expr: `hmac.verify(msgStr, sig, secretStr, hmac.SHA256)`, + vars: rawVars, + want: true, + }, + { + name: "verify_unknown_alg_string", + expr: `hmac.verify(msgStr, 'sig', secretStr, 'UNKNOWN_ALG')`, + vars: vars, + want: false, + }, + { + name: "verify_unknown_alg_bytes", + expr: `hmac.verify(bytes(msgStr), bytes('sig'), bytes(secretStr), 'UNKNOWN_ALG')`, + vars: vars, + want: false, + }, + } + + for _, tc := range invalidTests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, tc.vars) + if got != tc.want { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestHMACSpecificAlgorithmOptions(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library(hmaclib.Algorithm(crypto.SHA256)), + cel.Variable("msgStr", cel.StringType), + cel.Variable("secretStr", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "msgStr": "msg", + "secretStr": "key", + } + + tests := []struct { + name string + expr string + mode string // "compile_error" or "eval_error" or "success" + }{ + { + name: "sha256_success", + expr: `hmac.compute(msgStr, secretStr, hmac.SHA256)`, + mode: "success", + }, + { + name: "unregistered_constant_sha512", + expr: `hmac.compute(msgStr, secretStr, hmac.SHA512)`, + mode: "compile_error", + }, + { + name: "unregistered_literal_sha512", + expr: `hmac.compute(msgStr, secretStr, 'SHA512')`, + mode: "eval_error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ast, issues := env.Compile(tc.expr) + if tc.mode == "compile_error" { + if issues == nil || issues.Err() == nil { + t.Errorf("expected compile error for %q, got nil", tc.expr) + } + return + } + if issues != nil && issues.Err() != nil { + t.Fatalf("Compile(%q) failed unexpectedly: %v", tc.expr, issues.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("Program(%q) failed: %v", tc.expr, err) + } + _, _, evalErr := prg.Eval(vars) + if tc.mode == "eval_error" { + if evalErr == nil { + t.Errorf("expected eval error for %q, got nil", tc.expr) + } + } else if evalErr != nil { + t.Errorf("unexpected eval error for %q: %v", tc.expr, evalErr) + } + }) + } +} + +func TestHMACCustomAlgorithmOption(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library( + hmaclib.Algorithm(crypto.MD5, "MD5", "HASH-MD5"), + hmaclib.Algorithm(crypto.SHA1, "SHA1"), + ), + cel.Variable("msg", cel.StringType), + cel.Variable("secret", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + msgStr := "hello custom alg" + secretStr := "key" + vars := map[string]any{ + "msg": msgStr, + "secret": secretStr, + } + + hMD5 := hmac.New(md5.New, []byte(secretStr)) + hMD5.Write([]byte(msgStr)) + macMD5Bytes := hMD5.Sum(nil) + macMD5Hex := hex.EncodeToString(macMD5Bytes) + + hSHA1 := hmac.New(sha1.New, []byte(secretStr)) + hSHA1.Write([]byte(msgStr)) + macSHA1Hex := hex.EncodeToString(hSHA1.Sum(nil)) + + tests := []struct { + name string + expr string + want any + }{ + { + name: "md5_constant", + expr: `hmac.MD5`, + want: "MD5", + }, + { + name: "sha1_constant", + expr: `hmac.SHA1`, + want: "SHA1", + }, + { + name: "compute_custom_md5", + expr: `hmac.compute(msg, secret, hmac.MD5)`, + want: macMD5Bytes, + }, + { + name: "compute_custom_md5_alias", + expr: `hmac.compute(msg, secret, 'HASH-MD5')`, + want: macMD5Bytes, + }, + { + name: "verify_custom_md5_prefixed", + expr: `hmac.verify(msg, 'md5=` + macMD5Hex + `', secret, hmac.MD5)`, + want: true, + }, + { + name: "verify_custom_sha1_prefixed", + expr: `hmac.verify(msg, 'sha1=` + macSHA1Hex + `', secret, hmac.SHA1)`, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v (%T), want %v (%T)", tc.expr, got, got, tc.want, tc.want) + } + }) + } +} + +func TestHMACCommonAlgorithmsOption(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library(hmaclib.CommonAlgorithms()), + cel.Variable("msgStr", cel.StringType), + cel.Variable("secretStr", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "msgStr": "msg", + "secretStr": "key", + } + + tests := []struct { + name string + expr string + }{ + { + name: "sha256", + expr: `hmac.compute(msgStr, secretStr, hmac.SHA256)`, + }, + { + name: "sha512", + expr: `hmac.compute(msgStr, secretStr, hmac.SHA512)`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ast, issues := env.Compile(tc.expr) + if issues != nil && issues.Err() != nil { + t.Fatalf("Compile(%q) failed: %v", tc.expr, issues.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("Program(%q) failed: %v", tc.expr, err) + } + if _, _, err := prg.Eval(vars); err != nil { + t.Errorf("unexpected error for %s: %v", tc.name, err) + } + }) + } +} + +func TestHMACMaxPrefixLengthOption(t *testing.T) { + env, err := cel.NewEnv( + hmaclib.Library( + hmaclib.CommonAlgorithms(), + hmaclib.MaxPrefixLength(40), + hmaclib.Algorithm(crypto.SHA256, "very-long-prefix-custom-algorithm"), + ), + cel.Variable("msg", cel.StringType), + cel.Variable("secret", cel.StringType), + cel.Variable("sigStr", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + msg := "test" + secret := "key" + vars := map[string]any{ + "msg": msg, + "secret": secret, + } + mac := evalExpr(t, env, `hmac.compute(msg, secret, hmac.SHA256)`, vars).([]byte) + sigStr := "very-long-prefix-custom-algorithm=" + hex.EncodeToString(mac) + vars["sigStr"] = sigStr + + tests := []struct { + name string + expr string + want any + }{ + { + name: "verify_long_prefix", + expr: `hmac.verify(msg, sigStr, secret, hmac.SHA256)`, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if got != tc.want { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} diff --git a/ext/security/jwt/BUILD.bazel b/ext/security/jwt/BUILD.bazel new file mode 100644 index 000000000..6b6051ed0 --- /dev/null +++ b/ext/security/jwt/BUILD.bazel @@ -0,0 +1,35 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +package( + default_visibility = ["//visibility:public"], + licenses = ["notice"], # Apache 2.0 +) + +go_library( + name = "go_default_library", + srcs = [ + "jwt.go", + ], + importpath = "github.com/authzed/cel-go/ext/security/jwt", + deps = [ + "//cel:go_default_library", + "//common/types:go_default_library", + "//common/types/ref:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = [ + "export_test.go", + "jwt_test.go", + ], + embed = [ + ":go_default_library", + ], + deps = [ + "//cel:go_default_library", + "//common/types:go_default_library", + ], +) diff --git a/ext/security/jwt/export_test.go b/ext/security/jwt/export_test.go new file mode 100644 index 000000000..0c1663aff --- /dev/null +++ b/ext/security/jwt/export_test.go @@ -0,0 +1,20 @@ +// 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 +// +// 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 jwt + +// NewJWTLib constructs an internal jwtLib instance for testing. +func NewJWTLib() *jwtLib { + return &jwtLib{} +} diff --git a/ext/security/jwt/jwt.go b/ext/security/jwt/jwt.go new file mode 100644 index 000000000..0d9910df3 --- /dev/null +++ b/ext/security/jwt/jwt.go @@ -0,0 +1,447 @@ +// 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 +// +// 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 jwt implements CEL extension functions for JSON Web Token (JWT) parsing, claims inspection, and validation. +package jwt + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + "time" + + "github.com/authzed/cel-go/cel" + "github.com/authzed/cel-go/common/types" + "github.com/authzed/cel-go/common/types/ref" +) + +const ( + // jwtTokenType is the CEL type name for jwt.Token. + jwtTokenType = "jwt.Token" + maxTokenSize = 10 * 1024 * 1024 // 10MB maximum allowed token size +) + +func defaultNowFunc() time.Time { + return time.Now().UTC() +} + +// Library returns a cel.EnvOption to configure extended functions for JWT data handling and claims inspection. +func Library(options ...Option) cel.EnvOption { + l := &jwtLib{ + version: ^uint32(0), + now: defaultNowFunc, + } + for _, o := range options { + l = o(l) + } + return cel.Lib(l) +} + +// Option declares a functional operator for configuring JWT extension library behavior. +type Option func(*jwtLib) *jwtLib + +// Version sets the library version for JWT extensions. +func Version(version uint32) Option { + return func(l *jwtLib) *jwtLib { + l.version = version + return l + } +} + +// ValidateTimes enables automatic time validation (iat, nbf, exp) during token parsing with an optional clock leeway. +func ValidateTimes(leeway ...time.Duration) Option { + return func(l *jwtLib) *jwtLib { + l.validateTimes = true + if len(leeway) > 0 { + l.clockLeeway = leeway[0] + } + return l + } +} + +// Clock sets a custom clock function for time validation (defaults to time.Now). +func Clock(nowFunc func() time.Time) Option { + return func(l *jwtLib) *jwtLib { + l.now = nowFunc + return l + } +} + +// ClockLeeway sets the tolerance window when checking token time claims (iat, nbf, exp). +func ClockLeeway(leeway time.Duration) Option { + return func(l *jwtLib) *jwtLib { + l.clockLeeway = leeway + return l + } +} + +type jwtLib struct { + version uint32 + validateTimes bool + clockLeeway time.Duration + now func() time.Time +} + +// LibraryName returns the CEL library identifier string. +func (*jwtLib) LibraryName() string { + return "cel.lib.ext.security.jwt" +} + +// CompileOptions returns environment options for declaring CEL functions and types. +func (l *jwtLib) CompileOptions() []cel.EnvOption { + celTokenType := cel.ObjectType(jwtTokenType) + tokenType, err := types.NewNativeType(reflect.TypeFor[Token](), types.ParseStructTag("cel")) + if err != nil { + panic(fmt.Errorf("failed to create token type: %w", err)) + } + var adapt func() types.Adapter = func() types.Adapter { + return types.DefaultTypeAdapter + } + return []cel.EnvOption{ + cel.OptionalTypes(), + cel.Types(tokenType), + func(e *cel.Env) (*cel.Env, error) { + adapt = func() types.Adapter { return e.CELTypeAdapter() } + return e, nil + }, + cel.Function("jwt.parse", + cel.FunctionDocs( + "Parses a JWT token string into a structured Token representation.", + "Automatically strips leading 'Bearer ' prefixes if present.", + ), + cel.Overload("jwt_parse_string", + []*cel.Type{cel.StringType}, + cel.OptionalType(celTokenType), + cel.OverloadExamples( + "jwt.parse(tokenStr)", + "jwt.parse('Bearer eyJhbGciOi...')", + ), + cel.UnaryBinding(func(arg ref.Val) ref.Val { + tokenStr := arg.(types.String) + tok, err := ParseToken(string(tokenStr)) + if err != nil { + return types.NewErr("parse token failed: %w", err) + } + if l.validateTimes && !l.isTokenTimeValid(tok) { + return types.OptionalNone + } + return types.OptionalOf(adapt().NativeToValue(tok)) + }), + ), + ), + cel.Function("claim", + cel.FunctionDocs( + "Queries a custom claim value by key name from the JWT token payload, returning an optional dynamic value.", + ), + cel.MemberOverload("jwt_token_claim_string", + []*cel.Type{celTokenType, cel.StringType}, + cel.OptionalType(cel.DynType), + cel.OverloadExamples( + "token.claim('tenant_id')", + "token.claim('roles').orValue([])", + ), + cel.BinaryBinding(func(targetVal, claimNameVal ref.Val) ref.Val { + target := targetVal.Value().(*Token) + claimName := claimNameVal.(types.String) + return target.Claim(adapt(), string(claimName)) + }), + ), + cel.MemberOverload("jwt_token_opt_claim_string", + []*cel.Type{cel.OptionalType(celTokenType), cel.StringType}, + cel.OptionalType(cel.DynType), + cel.OverloadExamples( + "jwt.parse(tokenStr).claim('tenant_id')", + "jwt.parse(tokenStr).claim('tier').orValue('standard')", + ), + cel.BinaryBinding(func(targetVal, claimNameVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.OptionalNone + } + target, ok := optTarget.GetValue().Value().(*Token) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected jwt.Token") + } + claimName := claimNameVal.(types.String) + return target.Claim(adapt(), string(claimName)) + }), + ), + ), + cel.Function("presentedBy", + cel.FunctionDocs( + "Determines whether the token was presented by the expected authorized party (`azp`) or audience (`aud`) for the given issuer (`iss`).", + "When `azp` is present in the token, it takes precedence over `aud`.", + ), + cel.MemberOverload("jwt_token_presented_by_string_string", + []*cel.Type{celTokenType, cel.StringType, cel.StringType}, + cel.BoolType, + cel.OverloadExamples( + "token.presentedBy('https://accounts.google.com', 'my-client-app')", + ), + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + target := args[0].Value().(*Token) + issuer := args[1].(types.String) + presenter := args[2].(types.String) + return types.Bool(target.PresentedBy(string(issuer), string(presenter))) + }), + ), + cel.MemberOverload("jwt_token_opt_presented_by_string_string", + []*cel.Type{cel.OptionalType(celTokenType), cel.StringType, cel.StringType}, + cel.BoolType, + cel.OverloadExamples( + "jwt.parse(tokenStr).presentedBy('https://accounts.google.com', 'my-client-app')", + ), + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + optTarget := args[0].(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Token) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected jwt.Token") + } + issuer := args[1].(types.String) + presenter := args[2].(types.String) + return types.Bool(target.PresentedBy(string(issuer), string(presenter))) + }), + ), + ), + } +} + +// ProgramOptions returns program options for JWT extensions. +func (l *jwtLib) ProgramOptions() []cel.ProgramOption { + return nil +} + +func (l *jwtLib) isTokenTimeValid(tok *Token) bool { + return !l.validateTimes || tok.IsValidAt(l.now(), l.clockLeeway) +} + +// Token represents a parsed JWT token using Go native struct types. +// A Token instance and its associated Payload map MUST be treated as immutable once parsed or created. +type Token struct { + // Standard claims + Issuer string `json:"iss" cel:"issuer"` + Subject string `json:"sub" cel:"subject"` + Audience []string `json:"aud" cel:"aud"` + AuthorizedParty string `json:"azp,omitempty" cel:"azp"` + ExpiresAt time.Time `json:"exp" cel:"exp"` + NotBefore time.Time `json:"nbf" cel:"nbf"` + IssuedAt time.Time `json:"iat" cel:"iat"` + ID string `json:"jti,omitempty" cel:"id"` + + // Header derived fields + Algorithm string `json:"alg" cel:"alg"` + KeyID string `json:"kid" cel:"keyId"` + + // Raw JSON payload associated with the token including custom claims. + // Must be treated as read-only once initialized. + Payload map[string]any `json:"-" cel:"-"` +} + +// IsValidAt checks whether the token time claims (iat, nbf, exp) are valid at the given reference time with clock leeway tolerance. +func (t *Token) IsValidAt(refTime time.Time, leeway time.Duration) bool { + now := refTime.UTC() + lateNow := now.Add(leeway) + earlyNow := now.Add(-leeway) + + // Issued-at time is present and in the future. + if !t.IssuedAt.IsZero() && t.IssuedAt.Compare(lateNow) > 0 { + return false + } + // Not-before time is present and in the future. + if !t.NotBefore.IsZero() && t.NotBefore.Compare(lateNow) > 0 { + return false + } + // Expires-at time is present and expiry happened in the past. + if !t.ExpiresAt.IsZero() && t.ExpiresAt.Compare(earlyNow) <= 0 { + return false + } + // Inverted validity window: nbf <= exp + if !t.NotBefore.IsZero() && !t.ExpiresAt.IsZero() && t.NotBefore.Compare(t.ExpiresAt) > 0 { + return false + } + // Inverted validity window: iat <= exp + if !t.IssuedAt.IsZero() && !t.ExpiresAt.IsZero() && t.IssuedAt.Compare(t.ExpiresAt) > 0 { + return false + } + + return true +} + +// PresentedBy determines whether the token from the given issuer was presented by the expected authorized party (`azp`) or audience (`aud`). +// If the token contains an `azp` claim, it is checked against the `presenter`. Otherwise, the `aud` claim is checked. +func (t *Token) PresentedBy(issuer, presenter string) bool { + if t.Issuer != issuer { + return false + } + if t.AuthorizedParty != "" { + return t.AuthorizedParty == presenter + } + return slices.Contains(t.Audience, presenter) +} + +// Claim queries a claim value by key name using the provided types.Adapter, returning an optional dyn value. +func (t *Token) Claim(adapter types.Adapter, claimName string) ref.Val { + val, ok := t.Payload[claimName] + if !ok || val == nil { + return types.OptionalNone + } + refVal := adapter.NativeToValue(val) + if types.IsError(refVal) { + return refVal + } + return types.OptionalOf(refVal) +} + +// NewToken generates a `jwt.Token` instance from the JSON-decoded header and payload of a JWT. +// +// Signature validation of the token must be performed before passing the token to CEL. +// It is recommended that `IsValidAt` and `PresentedBy` are checked after creation of the token +// to ensure the token matches core content assumptions. +func NewToken(header, payload map[string]any) (*Token, error) { + alg, ok := header["alg"].(string) + if !ok || alg == "" { + return nil, fmt.Errorf("missing required header: 'alg'") + } + iss, ok := payload["iss"].(string) + if !ok || iss == "" { + return nil, fmt.Errorf("missing required claim: 'iss'") + } + sub, ok := payload["sub"].(string) + if !ok || sub == "" { + return nil, fmt.Errorf("missing required claim: 'sub'") + } + + var audience []string + switch a := payload["aud"].(type) { + case string: + if a != "" { + audience = []string{a} + } + case []any: + for _, item := range a { + s, ok := item.(string) + if !ok || s == "" { + return nil, fmt.Errorf("invalid claim 'aud': expected non-empty string in audience list, got %T", item) + } + audience = append(audience, s) + } + default: + if payload["aud"] != nil { + return nil, fmt.Errorf("invalid claim 'aud': expected string or array of strings, got %T", payload["aud"]) + } + } + if len(audience) == 0 { + return nil, fmt.Errorf("missing required claim: 'aud'") + } + + exp, err := types.ParseTimestamp(payload["exp"]) + if err != nil || exp.IsZero() { + return nil, fmt.Errorf("missing required claim: 'exp'") + } + iat, err := types.ParseTimestamp(payload["iat"]) + if err != nil || iat.IsZero() { + return nil, fmt.Errorf("missing required claim: 'iat'") + } + var nbf time.Time + if rawNbf, ok := payload["nbf"]; ok && rawNbf != nil { + parsedNbf, err := types.ParseTimestamp(rawNbf) + if err != nil { + return nil, fmt.Errorf("invalid claim 'nbf': %w", err) + } + nbf = parsedNbf + } + + return &Token{ + Algorithm: alg, + KeyID: optString(header, "kid"), + Issuer: iss, + Subject: sub, + Audience: audience, + AuthorizedParty: optString(payload, "azp"), + ExpiresAt: exp, + IssuedAt: iat, + NotBefore: nbf, + ID: optString(payload, "jti"), + Payload: payload, + }, nil +} + +// ParseToken parses a JWT token string into a structured Token. +// Verification of the token must be performed before passing the token to CEL. +func ParseToken(tokenStr string) (*Token, error) { + tokenStr = trimBearerPrefix(tokenStr) + if len(tokenStr) > maxTokenSize { + return nil, fmt.Errorf("token size exceeds maximum allowed limit of %d bytes", maxTokenSize) + } + + parts := strings.SplitN(tokenStr, ".", 4) + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("invalid token format: expected 2 or 3 parts, got %d", len(parts)) + } + + headerBytes, err := decodeBase64Segment(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to decode header: %w", err) + } + + var header map[string]any + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("failed to parse header JSON: %w", err) + } + + payloadBytes, err := decodeBase64Segment(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return nil, fmt.Errorf("failed to parse payload JSON: %w", err) + } + return NewToken(header, payload) +} + +func optString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func trimBearerPrefix(tokenStr string) string { + tokenStr = strings.TrimSpace(tokenStr) + if strings.HasPrefix(strings.ToLower(tokenStr), "bearer ") { + return strings.TrimSpace(tokenStr[7:]) + } + return tokenStr +} + +func decodeBase64Segment(seg string) ([]byte, error) { + if b, err := base64.RawURLEncoding.DecodeString(seg); err == nil { + return b, nil + } + if b, err := base64.URLEncoding.DecodeString(seg); err == nil { + return b, nil + } + if b, err := base64.RawStdEncoding.DecodeString(seg); err == nil { + return b, nil + } + return base64.StdEncoding.DecodeString(seg) +} diff --git a/ext/security/jwt/jwt_test.go b/ext/security/jwt/jwt_test.go new file mode 100644 index 000000000..8b889adf2 --- /dev/null +++ b/ext/security/jwt/jwt_test.go @@ -0,0 +1,1050 @@ +// 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 +// +// 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 jwt_test + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/authzed/cel-go/cel" + "github.com/authzed/cel-go/common/types" + "github.com/authzed/cel-go/common/types/ref" + "github.com/authzed/cel-go/ext/security/jwt" +) + +func createTestJWT(t *testing.T, header, payload map[string]any) string { + hBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("json.Marshal header failed: %v", err) + } + pBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal payload failed: %v", err) + } + + hB64 := base64.RawURLEncoding.EncodeToString(hBytes) + pB64 := base64.RawURLEncoding.EncodeToString(pBytes) + sigB64 := base64.RawURLEncoding.EncodeToString([]byte("signature-placeholder")) + + return hB64 + "." + pB64 + "." + sigB64 +} + +func evalExpr(t *testing.T, env *cel.Env, expr string, vars map[string]any) any { + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + t.Fatalf("Compile(%q) failed: %v", expr, issues.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("Program(%q) failed: %v", expr, err) + } + val, _, err := prg.Eval(vars) + if err != nil { + t.Fatalf("Eval(%q) failed: %v", expr, err) + } + return val.Value() +} + +func TestJWTParseAndPresentedBy(t *testing.T) { + header := map[string]any{ + "alg": "RS256", + "typ": "JWT", + "kid": "key-123", + } + payload := map[string]any{ + "iss": "https://auth.example.com", + "sub": "user_12345", + "aud": []string{"https://api.example.com", "https://admin.example.com"}, + "exp": time.Now().Add(1 * time.Hour).Unix(), + "nbf": time.Now().Add(-1 * time.Minute).Unix(), + "iat": time.Now().Add(-1 * time.Minute).Unix(), + "jti": "token-unique-id-999", + "roles": []string{"admin", "editor"}, + "tenant": "tenant_abc", + } + + tokenStr := createTestJWT(t, header, payload) + + env, err := cel.NewEnv( + jwt.Library(), + cel.Variable("tokenStr", cel.StringType), + cel.Variable("bearerToken", cel.StringType), + cel.Variable("upperBearerToken", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "tokenStr": tokenStr, + "bearerToken": "Bearer " + tokenStr, + "upperBearerToken": "BEARER " + tokenStr, + } + + tests := []struct { + name string + expr string + want any + }{ + { + name: "parse_has_value", + expr: `jwt.parse(tokenStr).hasValue()`, + want: true, + }, + { + name: "parse_bearer_prefix_has_value", + expr: `jwt.parse(bearerToken).hasValue()`, + want: true, + }, + { + name: "parse_uppercase_bearer_prefix_has_value", + expr: `jwt.parse(upperBearerToken).hasValue()`, + want: true, + }, + { + name: "token_algorithm", + expr: `jwt.parse(tokenStr).value().alg == 'RS256'`, + want: true, + }, + { + name: "token_issuer", + expr: `jwt.parse(tokenStr).value().issuer == 'https://auth.example.com'`, + want: true, + }, + { + name: "token_subject", + expr: `jwt.parse(tokenStr).value().subject == 'user_12345'`, + want: true, + }, + { + name: "token_key_id", + expr: `jwt.parse(tokenStr).value().keyId == 'key-123'`, + want: true, + }, + { + name: "token_id", + expr: `jwt.parse(tokenStr).value().id == 'token-unique-id-999'`, + want: true, + }, + { + name: "token_audience", + expr: `'https://api.example.com' in jwt.parse(tokenStr).value().aud`, + want: true, + }, + { + name: "token_presented_by_direct", + expr: `jwt.parse(tokenStr).value().presentedBy('https://auth.example.com', 'https://api.example.com')`, + want: true, + }, + { + name: "token_presented_by_on_optional", + expr: `jwt.parse(tokenStr).presentedBy('https://auth.example.com', 'https://api.example.com')`, + want: true, + }, + { + name: "token_presented_by_mismatch_iss", + expr: `jwt.parse(tokenStr).presentedBy('https://evil.com', 'https://api.example.com')`, + want: false, + }, + { + name: "token_presented_by_mismatch_aud", + expr: `jwt.parse(tokenStr).presentedBy('https://auth.example.com', 'https://wrong-aud.com')`, + want: false, + }, + { + name: "claim_tenant", + expr: `jwt.parse(tokenStr).value().claim('tenant').orValue('')`, + want: "tenant_abc", + }, + { + name: "claim_on_optional", + expr: `jwt.parse(tokenStr).claim('tenant').orValue('')`, + want: "tenant_abc", + }, + { + name: "claim_missing", + expr: `jwt.parse(tokenStr).claim('nonexistent').hasValue()`, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v (%T), want %v (%T)", tc.expr, got, got, tc.want, tc.want) + } + }) + } +} + +func TestParseUnverifiedTokenAndFieldVariations(t *testing.T) { + header := map[string]any{ + "alg": "ES256", + "kid": "k-42", + } + payload := map[string]any{ + "iss": "https://accounts.google.com", + "sub": "10987654321", + "aud": "my-client-id", + "exp": 1700000000, + "nbf": "1699990000", + "iat": 1699990000.5, + } + + tokStr := createTestJWT(t, header, payload) + + tok, err := jwt.ParseToken(tokStr) + if err != nil { + t.Fatalf("ParseToken failed: %v", err) + } + + tests := []struct { + name string + got any + want any + }{ + {"alg", tok.Algorithm, "ES256"}, + {"issuer", tok.Issuer, "https://accounts.google.com"}, + {"subject", tok.Subject, "10987654321"}, + {"key_id", tok.KeyID, "k-42"}, + {"audience", tok.Audience, []string{"my-client-id"}}, + {"exp", tok.ExpiresAt.Unix(), int64(1700000000)}, + {"nbf", tok.NotBefore.Unix(), int64(1699990000)}, + {"iat", tok.IssuedAt.Unix(), int64(1699990000)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if !reflect.DeepEqual(tc.got, tc.want) { + t.Errorf("%s = %v, want %v", tc.name, tc.got, tc.want) + } + }) + } +} + +func TestClaimsCustomTypes(t *testing.T) { + tok := &jwt.Token{ + Payload: map[string]any{ + "intNum": json.Number("42"), + "floatNum": json.Number("3.14"), + "strNum": json.Number("NaN"), + "rawJSON": json.RawMessage(`{"nested":"value"}`), + "rawMsgBad": json.RawMessage(`bad-json`), + "badNumFloat": json.Number("not-a-number"), + "simpleStr": "hello", + }, + } + + adapter := types.DefaultTypeAdapter + + tests := []struct { + name string + claimName string + validate func(t *testing.T, val ref.Val) + }{ + { + name: "json_number_int", + claimName: "intNum", + validate: func(t *testing.T, val ref.Val) { + if val.Value() != int64(42) { + t.Errorf("expected 42, got %v", val.Value()) + } + }, + }, + { + name: "json_number_float", + claimName: "floatNum", + validate: func(t *testing.T, val ref.Val) { + if val.Value() != float64(3.14) { + t.Errorf("expected 3.14, got %v", val.Value()) + } + }, + }, + { + name: "json_number_nan_string", + claimName: "strNum", + validate: func(t *testing.T, val ref.Val) { + if val == types.OptionalNone { + t.Errorf("expected non-empty optional for strNum") + } + }, + }, + { + name: "raw_json_message", + claimName: "rawJSON", + validate: func(t *testing.T, val ref.Val) { + if val == types.OptionalNone { + t.Errorf("expected rawJSON to be parsed") + } + }, + }, + { + name: "raw_msg_bad_conversion_error", + claimName: "rawMsgBad", + validate: func(t *testing.T, val ref.Val) { + if !types.IsError(val) { + t.Errorf("expected error ref.Val for invalid json.RawMessage, got %v (%T)", val, val) + } + }, + }, + { + name: "bad_num_conversion_error", + claimName: "badNumFloat", + validate: func(t *testing.T, val ref.Val) { + if !types.IsError(val) { + t.Errorf("expected error ref.Val for invalid json.Number, got %v (%T)", val, val) + } + }, + }, + { + name: "simple_string", + claimName: "simpleStr", + validate: func(t *testing.T, val ref.Val) { + if val.Value() != "hello" { + t.Errorf("expected 'hello', got %v", val.Value()) + } + }, + }, + { + name: "nonexistent_claim", + claimName: "nonexistent", + validate: func(t *testing.T, val ref.Val) { + if val != types.OptionalNone { + t.Errorf("expected None for nonexistent claim, got %v", val) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + val := tok.Claim(adapter, tc.claimName) + tc.validate(t, val) + }) + } +} + +func TestJWTTimestampTypes(t *testing.T) { + header := map[string]any{"alg": "RS256", "typ": "JWT"} + + tests := []struct { + name string + payload map[string]any + wantExp int64 + wantIat int64 + wantNbf int64 + }{ + { + name: "float64_and_string", + payload: map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": float64(1700000000.5), + "iat": int64(1699990000), + "nbf": "1699990000", + }, + wantExp: 1700000000, + wantIat: 1699990000, + wantNbf: 1699990000, + }, + { + name: "uint64_int32_float32", + payload: map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": uint64(1700000000), + "iat": int32(1699990000), + "nbf": float32(1699990000), + }, + wantExp: 1700000000, + wantIat: 1699990000, + wantNbf: 1699990000, + }, + { + name: "int_uint_uint32", + payload: map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": int(1700000000), + "iat": uint(1699990000), + "nbf": uint32(1699990000), + }, + wantExp: 1700000000, + wantIat: 1699990000, + wantNbf: 1699990000, + }, + { + name: "string_float", + payload: map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": "1700000000.5", + "iat": 1699900000, + }, + wantExp: 1700000000, + wantIat: 1699900000, + wantNbf: 0, + }, + { + name: "json_number_float", + payload: map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": json.Number("1700000000.75"), + "iat": 1699900000, + }, + wantExp: 1700000000, + wantIat: 1699900000, + wantNbf: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tokStr := createTestJWT(t, header, tc.payload) + tok, err := jwt.ParseToken(tokStr) + if err != nil { + t.Fatalf("ParseToken failed: %v", err) + } + if tok.ExpiresAt.Unix() != tc.wantExp { + t.Errorf("exp = %v, want %v", tok.ExpiresAt.Unix(), tc.wantExp) + } + if tok.IssuedAt.Unix() != tc.wantIat { + t.Errorf("iat = %v, want %v", tok.IssuedAt.Unix(), tc.wantIat) + } + if tc.wantNbf != 0 && tok.NotBefore.Unix() != tc.wantNbf { + t.Errorf("nbf = %v, want %v", tok.NotBefore.Unix(), tc.wantNbf) + } + }) + } +} + +func TestJWTValidateTimesOption(t *testing.T) { + fixedNow := time.Unix(1700000000, 0).UTC() + header := map[string]any{"alg": "RS256", "typ": "JWT"} + + tokValid := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": fixedNow.Add(-1 * time.Hour).Unix(), + "nbf": fixedNow.Add(-1 * time.Hour).Unix(), + "exp": fixedNow.Add(1 * time.Hour).Unix(), + }) + + tokExpired := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": fixedNow.Add(-2 * time.Hour).Unix(), + "exp": fixedNow.Add(-10 * time.Minute).Unix(), + }) + + tokFutureNbf := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": fixedNow.Add(-1 * time.Hour).Unix(), + "nbf": fixedNow.Add(10 * time.Minute).Unix(), + "exp": fixedNow.Add(1 * time.Hour).Unix(), + }) + + tokFutureIat := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": fixedNow.Add(10 * time.Minute).Unix(), + "exp": fixedNow.Add(1 * time.Hour).Unix(), + }) + + tokInvertedWindow := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": fixedNow.Add(-30 * time.Minute).Unix(), + "nbf": fixedNow.Add(-10 * time.Minute).Unix(), + "exp": fixedNow.Add(-20 * time.Minute).Unix(), + }) + + realNow := time.Now() + tokValidRealTime := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": realNow.Add(-1 * time.Hour).Unix(), + "nbf": realNow.Add(-1 * time.Hour).Unix(), + "exp": realNow.Add(1 * time.Hour).Unix(), + }) + + tokExpiredRealTime := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-aud", + "iat": realNow.Add(-2 * time.Hour).Unix(), + "exp": realNow.Add(-1 * time.Hour).Unix(), + }) + + tests := []struct { + name string + options []jwt.Option + tokenStr string + wantPass bool + }{ + { + name: "default_no_validation_allows_expired", + options: nil, + tokenStr: tokExpired, + wantPass: true, + }, + { + name: "validated_valid_token_passes", + options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, + tokenStr: tokValid, + wantPass: true, + }, + { + name: "validated_expired_token_rejected", + options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, + tokenStr: tokExpired, + wantPass: false, + }, + { + name: "validated_future_nbf_rejected", + options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, + tokenStr: tokFutureNbf, + wantPass: false, + }, + { + name: "validated_future_iat_rejected", + options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, + tokenStr: tokFutureIat, + wantPass: false, + }, + { + name: "leeway_allows_token_expired_within_window", + options: []jwt.Option{jwt.ValidateTimes(30 * time.Minute), jwt.Clock(func() time.Time { return fixedNow })}, + tokenStr: tokExpired, + wantPass: true, + }, + { + name: "leeway_rejects_inverted_nbf_after_exp", + options: []jwt.Option{jwt.ValidateTimes(30 * time.Minute), jwt.Clock(func() time.Time { return fixedNow })}, + tokenStr: tokInvertedWindow, + wantPass: false, + }, + { + name: "validated_default_clock_valid_token", + options: []jwt.Option{jwt.ValidateTimes()}, + tokenStr: tokValidRealTime, + wantPass: true, + }, + { + name: "validated_default_clock_expired_token", + options: []jwt.Option{jwt.ValidateTimes()}, + tokenStr: tokExpiredRealTime, + wantPass: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + env, err := cel.NewEnv( + jwt.Library(tc.options...), + cel.Variable("tok", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + got := evalExpr(t, env, `jwt.parse(tok).hasValue()`, map[string]any{"tok": tc.tokenStr}) + if got != tc.wantPass { + t.Errorf("jwt.parse(tok).hasValue() = %v, want %v", got, tc.wantPass) + } + }) + } +} + +func TestJWTOptionalReceiverChaining(t *testing.T) { + header := map[string]any{"alg": "RS256", "typ": "JWT"} + goodTokStr := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "tag": "prod", + "exp": 1700000000, + "iat": 1699900000, + }) + + envExpired, err := cel.NewEnv( + jwt.Library(jwt.ValidateTimes(), jwt.Clock(func() time.Time { return time.Unix(2000000000, 0) })), + cel.Variable("tok", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + tests := []struct { + name string + env *cel.Env + expr string + want any + }{ + { + name: "presented_by_on_optional_none", + env: envExpired, + expr: `jwt.parse(tok).presentedBy('https://auth.example.com', 'my-client')`, + want: false, + }, + { + name: "claim_on_optional_none", + env: envExpired, + expr: `jwt.parse(tok).claim('tag').orValue('default')`, + want: "default", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, tc.env, tc.expr, map[string]any{"tok": goodTokStr}) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestJWTDirectTokenVariables(t *testing.T) { + header := map[string]any{"alg": "RS256", "typ": "JWT"} + goodTokStr := createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "tag": "prod", + "exp": 1700000000, + "iat": 1699900000, + }) + + tok, err := jwt.ParseToken(goodTokStr) + if err != nil { + t.Fatalf("ParseToken failed: %v", err) + } + + env, err := cel.NewEnv( + jwt.Library(), + cel.Variable("t", cel.ObjectType("jwt.Token")), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{"t": tok} + + tests := []struct { + name string + expr string + want any + }{ + { + name: "presented_by_direct_match", + expr: `t.presentedBy('https://auth.example.com', 'my-client')`, + want: true, + }, + { + name: "presented_by_direct_mismatch", + expr: `t.presentedBy('https://auth.example.com', 'wrong-client')`, + want: false, + }, + { + name: "claim_direct_present", + expr: `t.claim('tag').orValue('')`, + want: "prod", + }, + { + name: "claim_direct_missing", + expr: `t.claim('nonexistent').orValue('default')`, + want: "default", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestJWTPresentedByWithAuthorizedPartyAZP(t *testing.T) { + header := map[string]any{"alg": "RS256", "typ": "JWT"} + + tokPayload := map[string]any{ + "iss": "https://accounts.google.com", + "sub": "user-456", + "aud": "https://api.example.com", + "azp": "frontend-client-app-id", + "exp": 1700000000, + "iat": 1699900000, + } + tokStr := createTestJWT(t, header, tokPayload) + + tokNoAZP := createTestJWT(t, header, map[string]any{ + "iss": "https://accounts.google.com", + "sub": "user-456", + "aud": "https://api.example.com", + "exp": 1700000000, + "iat": 1699900000, + }) + + env, err := cel.NewEnv( + jwt.Library(), + cel.Variable("tokStr", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + tests := []struct { + name string + expr string + tokenStr string + want any + }{ + { + name: "azp_field_access", + expr: `jwt.parse(tokStr).value().azp`, + tokenStr: tokStr, + want: "frontend-client-app-id", + }, + { + name: "presented_by_matches_azp", + expr: `jwt.parse(tokStr).presentedBy('https://accounts.google.com', 'frontend-client-app-id')`, + tokenStr: tokStr, + want: true, + }, + { + name: "presented_by_rejects_aud_when_azp_exists", + expr: `jwt.parse(tokStr).presentedBy('https://accounts.google.com', 'https://api.example.com')`, + tokenStr: tokStr, + want: false, + }, + { + name: "presented_by_falls_back_to_aud_when_azp_omitted", + expr: `jwt.parse(tokStr).presentedBy('https://accounts.google.com', 'https://api.example.com')`, + tokenStr: tokNoAZP, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, map[string]any{"tokStr": tc.tokenStr}) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestJWTParsingErrorsAndEncodings(t *testing.T) { + header := map[string]any{"alg": "RS256", "typ": "JWT"} + validPayload := map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": 1700000000, + "iat": 1699900000, + } + + goodHeaderB64 := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) + validPayloadBytes, _ := json.Marshal(validPayload) + validPayloadB64 := base64.RawURLEncoding.EncodeToString(validPayloadBytes) + badJSONB64 := base64.RawURLEncoding.EncodeToString([]byte(`not-json`)) + + tests := []struct { + name string + tokenStr string + errMsg string + }{ + { + name: "one_segment", + tokenStr: "one", + errMsg: "invalid token format", + }, + { + name: "four_segments", + tokenStr: "one.two.three.four", + errMsg: "invalid token format", + }, + { + name: "empty_token", + tokenStr: "", + errMsg: "invalid token format", + }, + { + name: "bad_header_base64", + tokenStr: "!bad!.payload.sig", + errMsg: "failed to decode header", + }, + { + name: "non_json_header", + tokenStr: badJSONB64 + "." + validPayloadB64 + ".sig", + errMsg: "failed to parse header JSON", + }, + { + name: "missing_header_alg", + tokenStr: base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT"}`)) + "." + validPayloadB64 + ".sig", + errMsg: "missing required header: 'alg'", + }, + { + name: "bad_payload_base64", + tokenStr: goodHeaderB64 + ".!bad!.sig", + errMsg: "failed to decode payload", + }, + { + name: "non_json_payload", + tokenStr: goodHeaderB64 + "." + badJSONB64 + ".sig", + errMsg: "failed to parse payload JSON", + }, + { + name: "exceeds_max_token_size", + tokenStr: strings.Repeat("a", 11*1024*1024), + errMsg: "token size exceeds maximum allowed limit", + }, + { + name: "malformed_nbf_claim", + tokenStr: createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": "my-client", + "exp": 1700000000, + "iat": 1699900000, + "nbf": "invalid-timestamp", + }), + errMsg: "invalid claim 'nbf'", + }, + { + name: "non_string_element_in_aud_list", + tokenStr: createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": []any{"client-1", 12345}, + "exp": 1700000000, + "iat": 1699900000, + }), + errMsg: "invalid claim 'aud'", + }, + { + name: "invalid_aud_type", + tokenStr: createTestJWT(t, header, map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-123", + "aud": 12345, + "exp": 1700000000, + "iat": 1699900000, + }), + errMsg: "invalid claim 'aud'", + }, + { + name: "excessive_dots", + tokenStr: strings.Repeat(".", 100), + errMsg: "invalid token format", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := jwt.ParseToken(tc.tokenStr) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.errMsg) + } + if !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("error = %q, want error containing %q", err.Error(), tc.errMsg) + } + }) + } + + requiredClaims := []string{"iss", "sub", "aud", "exp", "iat"} + for _, claim := range requiredClaims { + t.Run("missing_claim_"+claim, func(t *testing.T) { + p := make(map[string]any) + for k, v := range validPayload { + if k != claim { + p[k] = v + } + } + tokStr := createTestJWT(t, header, p) + if _, err := jwt.ParseToken(tokStr); err == nil { + t.Errorf("expected error when missing required claim %q, got nil", claim) + } + }) + } +} + +func TestTokenIsValidAt(t *testing.T) { + now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) + leeway := 5 * time.Minute + + tests := []struct { + name string + token jwt.Token + refTime time.Time + leeway time.Duration + wantValid bool + }{ + { + name: "valid token within active window", + token: jwt.Token{ + IssuedAt: now.Add(-1 * time.Hour), + NotBefore: now.Add(-30 * time.Minute), + ExpiresAt: now.Add(1 * time.Hour), + }, + refTime: now, + leeway: 0, + wantValid: true, + }, + { + name: "issued-at exactly now", + token: jwt.Token{ + IssuedAt: now, + ExpiresAt: now.Add(1 * time.Hour), + }, + refTime: now, + leeway: 0, + wantValid: true, + }, + { + name: "issued-at in future within leeway", + token: jwt.Token{ + IssuedAt: now.Add(3 * time.Minute), + ExpiresAt: now.Add(1 * time.Hour), + }, + refTime: now, + leeway: leeway, + wantValid: true, + }, + { + name: "issued-at in future beyond leeway", + token: jwt.Token{ + IssuedAt: now.Add(10 * time.Minute), + ExpiresAt: now.Add(1 * time.Hour), + }, + refTime: now, + leeway: leeway, + wantValid: false, + }, + { + name: "not-before in future within leeway", + token: jwt.Token{ + IssuedAt: now.Add(-10 * time.Minute), + NotBefore: now.Add(3 * time.Minute), + ExpiresAt: now.Add(1 * time.Hour), + }, + refTime: now, + leeway: leeway, + wantValid: true, + }, + { + name: "not-before in future beyond leeway", + token: jwt.Token{ + IssuedAt: now.Add(-10 * time.Minute), + NotBefore: now.Add(10 * time.Minute), + ExpiresAt: now.Add(1 * time.Hour), + }, + refTime: now, + leeway: leeway, + wantValid: false, + }, + { + name: "expired token in past within leeway", + token: jwt.Token{ + IssuedAt: now.Add(-1 * time.Hour), + ExpiresAt: now.Add(-3 * time.Minute), + }, + refTime: now, + leeway: leeway, + wantValid: true, + }, + { + name: "expired token in past beyond leeway", + token: jwt.Token{ + IssuedAt: now.Add(-1 * time.Hour), + ExpiresAt: now.Add(-10 * time.Minute), + }, + refTime: now, + leeway: leeway, + wantValid: false, + }, + { + name: "expired token exactly at negative leeway boundary", + token: jwt.Token{ + IssuedAt: now.Add(-1 * time.Hour), + ExpiresAt: now.Add(-5 * time.Minute), + }, + refTime: now, + leeway: leeway, + wantValid: false, + }, + { + name: "inverted nbf > exp", + token: jwt.Token{ + IssuedAt: now.Add(-1 * time.Hour), + NotBefore: now.Add(30 * time.Minute), + ExpiresAt: now.Add(15 * time.Minute), + }, + refTime: now, + leeway: 0, + wantValid: false, + }, + { + name: "inverted iat > exp", + token: jwt.Token{ + IssuedAt: now.Add(30 * time.Minute), + ExpiresAt: now.Add(15 * time.Minute), + }, + refTime: now, + leeway: 1 * time.Hour, + wantValid: false, + }, + { + name: "empty claims (all zero time)", + token: jwt.Token{ + Issuer: "https://auth.example.com", + }, + refTime: now, + leeway: 0, + wantValid: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.token.IsValidAt(tc.refTime, tc.leeway) + if got != tc.wantValid { + t.Errorf("token.IsValidAt(%v, %v) = %v, want %v", tc.refTime, tc.leeway, got, tc.wantValid) + } + }) + } +} diff --git a/ext/sets.go b/ext/sets.go index 79e256b22..143d33b41 100644 --- a/ext/sets.go +++ b/ext/sets.go @@ -18,6 +18,7 @@ import ( "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/checker" "github.com/authzed/cel-go/common/ast" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/operators" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" @@ -248,7 +249,7 @@ func trackSetsCost(costFactor float64) interpreter.FunctionTracker { return func(args []ref.Val, _ ref.Val) *uint64 { lhsSize := actualSize(args[0]) rhsSize := actualSize(args[1]) - cost := safeAdd(callCost, uint64(float64(lhsSize*rhsSize)*costFactor)) - return &cost + total := cost.SafeAdd(callCost, uint64(float64(lhsSize*rhsSize)*costFactor)) + return &total } } diff --git a/ext/strings.go b/ext/strings.go index 5e5b433fe..3c41aaff7 100644 --- a/ext/strings.go +++ b/ext/strings.go @@ -30,6 +30,7 @@ import ( "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/checker" "github.com/authzed/cel-go/common" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" "github.com/authzed/cel-go/common/types/traits" @@ -972,7 +973,7 @@ func estimateStringReplaceCost(estimator checker.CostEstimator, target *checker. searchCost := atLeastOne(targetSize).Multiply(needleSize).MultiplyByCostFactor(stringCostFactor) replacementSize := estimateSize(estimator, args[1]).Add(fixedSizeEstimate(1)) - allReplacedSize := safeMul(safeAdd(targetSize.Max, 1), replacementSize.Max) + allReplacedSize := cost.SafeMultiply(cost.SafeAdd(targetSize.Max, 1), replacementSize.Max) resultMinSize := targetSize.Min if resultMinSize > replacementSize.Min { resultMinSize = replacementSize.Min @@ -1017,10 +1018,10 @@ func estimateStringJoinCost(estimator checker.CostEstimator, target *checker.Ast traversalCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(stringCostFactor) // Result size: sum of element sizes + (n-1) * separator size. // Worst case estimate: use list size * max element size + list size * separator size. - maxResultSize := safeAdd(safeMul(targetSize.Max, (safeAdd(1, sepSize.Max))), sepSize.Max) + maxResultSize := cost.SafeAdd(cost.SafeMultiply(targetSize.Max, cost.SafeAdd(1, sepSize.Max)), sepSize.Max) resultSize := rangedSizeEstimate(0, maxResultSize) - cost := traversalCost.Add(resultSize.MultiplyByCostFactor(1)).Add(callCostEstimate) - return callEstimate(cost, &resultSize) + estimate := traversalCost.Add(resultSize.MultiplyByCostFactor(1)).Add(callCostEstimate) + return callEstimate(estimate, &resultSize) } // Runtime cost tracking functions for string extensions. @@ -1030,24 +1031,23 @@ func estimateStringJoinCost(estimator checker.CostEstimator, target *checker.Ast // trackStringCharAtCost tracks runtime cost for O(n) string operations. func trackStringCharAtCost(args []ref.Val, result ref.Val) *uint64 { - size := float64(actualSize(args[0])) * stringCostFactor - cost := safeAdd(callCost, uint64(math.Ceil(size)), 1) - return &cost + total := cost.SafeAdd(callCost, cost.SafeMultiplyByFactor(actualSize(args[0]), stringCostFactor), 1) + return &total } // trackStringTransformCost tracks runtime cost for O(n) string operations. func trackStringTransformCost(args []ref.Val, result ref.Val) *uint64 { - transformCost := math.Ceil(float64(actualSize(args[0])) * stringCostFactor) + transformCost := cost.SafeMultiplyByFactor(actualSize(args[0]), stringCostFactor) resultSize := actualSize(result) - cost := safeAdd(callCost, uint64(transformCost), resultSize) - return &cost + total := cost.SafeAdd(callCost, transformCost, resultSize) + return &total } // trackStringSearchCost tracks runtime cost for O(n*m) string search operations. func trackStringSearchCost(args []ref.Val, _ ref.Val) *uint64 { - searchCost := float64(actualSize(args[0])*actualSize(args[1])) * stringCostFactor - cost := safeAdd(uint64(math.Ceil(searchCost)), callCost) - return &cost + searchSize := cost.SafeMultiply(actualSize(args[0]), actualSize(args[1])) + total := cost.SafeAdd(cost.SafeMultiplyByFactor(searchSize, stringCostFactor), callCost) + return &total } // trackStringReplaceCost tracks runtime cost for string replace operations, @@ -1061,24 +1061,24 @@ func trackStringReplaceCost(args []ref.Val, result ref.Val) *uint64 { if needleSize == 0 { needleSize = 1 } - searchCost := uint64(math.Ceil(float64(targetSize*needleSize) * stringCostFactor)) - cost := safeAdd(callCost, searchCost, actualSize(result)) - return &cost + searchCost := cost.SafeMultiplyByFactor(cost.SafeMultiply(targetSize, needleSize), stringCostFactor) + total := cost.SafeAdd(callCost, searchCost, actualSize(result)) + return &total } // trackStringSplitCost tracks runtime cost for string split operations, // accounting for traversal and list allocation. func trackStringSplitCost(args []ref.Val, result ref.Val) *uint64 { - traversalCost := float64(safeAdd(actualSize(args[0]), 1)) * stringCostFactor + traversalCost := cost.SafeMultiplyByFactor(cost.SafeAdd(actualSize(args[0]), 1), stringCostFactor) resultSize := actualSize(result) - cost := safeAdd(callCost, uint64(math.Ceil(traversalCost)), resultSize, common.ListCreateBaseCost) - return &cost + total := cost.SafeAdd(callCost, traversalCost, resultSize, common.ListCreateBaseCost) + return &total } // trackStringJoinCost tracks runtime cost for string join operations, // accounting for traversal and the size of the result. func trackStringJoinCost(args []ref.Val, result ref.Val) *uint64 { - traversalCost := float64(safeAdd(actualSize(args[0]), 1)) * stringCostFactor - cost := safeAdd(callCost, uint64(math.Ceil(traversalCost)), actualSize(result)) - return &cost + traversalCost := cost.SafeMultiplyByFactor(cost.SafeAdd(actualSize(args[0]), 1), stringCostFactor) + total := cost.SafeAdd(callCost, traversalCost, actualSize(result)) + return &total } diff --git a/interpreter/BUILD.bazel b/interpreter/BUILD.bazel index 2a349f0bf..a1257a771 100644 --- a/interpreter/BUILD.bazel +++ b/interpreter/BUILD.bazel @@ -28,6 +28,7 @@ go_library( "//common:go_default_library", "//common/ast:go_default_library", "//common/containers:go_default_library", + "//common/cost:go_default_library", "//common/functions:go_default_library", "//common/operators:go_default_library", "//common/overloads:go_default_library", diff --git a/interpreter/decorators.go b/interpreter/decorators.go index 9e55f05f0..f190a4e53 100644 --- a/interpreter/decorators.go +++ b/interpreter/decorators.go @@ -15,6 +15,8 @@ package interpreter import ( + "fmt" + "github.com/authzed/cel-go/common/overloads" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" @@ -169,6 +171,77 @@ func decRegexOptimizer(regexOptimizations ...*RegexOptimization) InterpretableDe } } +func decRegexProgramSizeLimit(limit int) InterpretableDecoratorV2 { + return func(i InterpretableV2) (InterpretableV2, error) { + if limit <= 0 { + return i, nil + } + call, ok := i.(InterpretableCall) + if !ok { + return i, nil + } + if !isRegexFunction(call.Function(), call.OverloadID()) || len(call.Args()) < 2 { + return i, nil + } + regexArg := call.Args()[1] + if constVal, isConst := regexArg.(InterpretableConst); isConst { + if pattern, ok := constVal.Value().(types.String); ok { + sz, err := types.RegexProgramSize(string(pattern)) + if err != nil { + return i, nil + } + if sz > limit { + return nil, fmt.Errorf("regex program size %d exceeds limit of %d", sz, limit) + } + } + return i, nil + } + return ®exLimitCall{InterpretableCall: call, limit: limit}, nil + } +} + +func isRegexFunction(fn, overload string) bool { + switch fn { + case overloads.Matches, "regex.extract", "regex.extractAll", "regex.replace": + return true + } + switch overload { + case overloads.Matches, overloads.MatchesString, + "regex_extract_string_string", "regex_extractAll_string_string", + "regex_replace_string_string_string", "regex_replace_string_string_string_int": + return true + } + return false +} + +type regexLimitCall struct { + InterpretableCall + limit int +} + +func (r *regexLimitCall) Exec(frame *ExecutionFrame) ref.Val { + args := r.Args() + if len(args) >= 2 { + patternVal := args[1].Exec(frame) + if types.IsError(patternVal) { + return patternVal + } + if types.IsUnknown(patternVal) { + return patternVal + } + if pat, ok := patternVal.(types.String); ok { + sz, err := types.RegexProgramSize(string(pat)) + if err != nil { + return types.WrapErr(err) + } + if sz > r.limit { + return types.WrapErr(fmt.Errorf("regex program size %d exceeds limit of %d", sz, r.limit)) + } + } + } + return r.InterpretableCall.Exec(frame) +} + func maybeOptimizeConstUnary(i InterpretableV2, call InterpretableCall) (InterpretableV2, error) { args := call.Args() if len(args) != 1 { diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index 92aa9d0cd..493b82b71 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -101,7 +101,6 @@ func EvalStateObserver(opts ...evalStateOption) PlannerOption { return nil, errors.New("eval state factory not configured") } p.observers = append(p.observers, et) - p.decorators = append(p.decorators, decObserveEval(et.Observe)) return p, nil } } @@ -227,6 +226,11 @@ func CompileRegexConstants(regexOptimizations ...*RegexOptimization) PlannerOpti return CustomDecoratorV2(decRegexOptimizer(regexOptimizations...)) } +// RegexProgramSizeLimit caps the maximum regex program plan size permitted during evaluation. +func RegexProgramSizeLimit(limit int) PlannerOption { + return CustomDecoratorV2(decRegexProgramSizeLimit(limit)) +} + type exprInterpreter struct { dispatcher Dispatcher container *containers.Container diff --git a/interpreter/interpreter_test.go b/interpreter/interpreter_test.go index 90e95f0f1..5d27997a7 100644 --- a/interpreter/interpreter_test.go +++ b/interpreter/interpreter_test.go @@ -2113,6 +2113,72 @@ func TestInterpreter_InterruptableEval(t *testing.T) { } } +func TestInterpreter_RegexProgramSizeLimit(t *testing.T) { + tcConst := testCase{ + expr: `'hello'.matches('(a|b)*[0-9]+')`, + } + _, _, err := program(t, &tcConst, RegexProgramSizeLimit(5)) + if err == nil { + t.Fatalf("expected program creation error for constant regex exceeding limit") + } + if !strings.Contains(err.Error(), "regex program size 8 exceeds limit of 5") { + t.Errorf("got error %v, wanted error containing 'regex program size 8 exceeds limit of 5'", err) + } + + tcDyn := testCase{ + expr: `'hello'.matches(pattern)`, + vars: []*decls.VariableDecl{ + decls.NewVariable("pattern", types.StringType), + }, + in: map[string]any{ + "pattern": "(a|b)*[0-9]+", + }, + } + prg, frame, err := program(t, &tcDyn, RegexProgramSizeLimit(5)) + if err != nil { + t.Fatalf("program() failed: %v", err) + } + out := prg.Exec(frame) + frame.Close() + if !types.IsError(out) || !strings.Contains(out.(*types.Err).String(), "regex program size 8 exceeds limit of 5") { + t.Errorf("got %v, wanted regex program size limit error", out) + } + + tcValid := testCase{ + expr: `'hello'.matches(pattern)`, + vars: []*decls.VariableDecl{ + decls.NewVariable("pattern", types.StringType), + }, + in: map[string]any{ + "pattern": "el*", + }, + out: true, + } + prgValid, frameValid, err := program(t, &tcValid, RegexProgramSizeLimit(5)) + if err != nil { + t.Fatalf("program() failed: %v", err) + } + outValid := prgValid.Exec(frameValid) + frameValid.Close() + if outValid != types.True { + t.Errorf("got %v, wanted true", outValid) + } + + // Non-regex function should not be modified by RegexProgramSizeLimit decorator + tcOther := testCase{ + expr: `'hello'.contains('e')`, + } + prgOther, frameOther, err := program(t, &tcOther, RegexProgramSizeLimit(5)) + if err != nil { + t.Fatalf("program() failed: %v", err) + } + outOther := prgOther.Exec(frameOther) + frameOther.Close() + if outOther != types.True { + t.Errorf("got %v, wanted true", outOther) + } +} + func TestInterpreter_ExhaustiveLogicalOrEquals(t *testing.T) { // a || b == "b" // Operator "==" is at Expr 4, should be evaluated though "a" is true @@ -2573,9 +2639,13 @@ func newTestEnv(t testing.TB, cont *containers.Container, reg *types.Registry) * func newTestRegistry(t testing.TB, opts ...types.RegistryOption) *types.Registry { t.Helper() - reg, err := types.NewProtoRegistry(opts...) + var o []any + for _, opt := range opts { + o = append(o, opt) + } + reg, err := types.NewRegistry(o...) if err != nil { - t.Fatalf("types.NewProtoRegistry() failed: %v", err) + t.Fatalf("types.NewRegistry() failed: %v", err) } return reg } diff --git a/interpreter/planner.go b/interpreter/planner.go index d9ccad4a3..035b221d9 100644 --- a/interpreter/planner.go +++ b/interpreter/planner.go @@ -23,6 +23,7 @@ import ( "github.com/authzed/cel-go/common/functions" "github.com/authzed/cel-go/common/operators" "github.com/authzed/cel-go/common/types" + "github.com/authzed/cel-go/common/types/ref" ) // newPlanner creates an interpretablePlanner which references a Dispatcher, TypeProvider, @@ -73,6 +74,12 @@ type planBuilder struct { // such as state-tracking, expression re-write, and possibly efficient thread-safe memoization of // repeated expressions. func (p *planner) Plan(expr ast.Expr) (InterpretableV2, error) { + if len(p.observers) != 0 { + // A single decorator reports to every observer. One decorator per observer would not + // work, since the second decorator would find the node already wrapped by the first and + // leave it alone, silently dropping the second observer's observations. + p.decorators = append(p.decorators, decObserveEval(observeAll(p.observers))) + } pb := &planBuilder{planner: p, localVars: make(map[string]int)} i, err := pb.plan(expr) if err != nil { @@ -84,6 +91,18 @@ func (p *planner) Plan(expr ast.Expr) (InterpretableV2, error) { return &ObservableInterpretable{InterpretableV2: i, observers: p.observers}, nil } +// observeAll returns an EvalObserver which reports each observation to all of the observers. +func observeAll(observers []StatefulObserver) EvalObserver { + if len(observers) == 1 { + return observers[0].Observe + } + return func(vars Activation, id int64, programStep any, value ref.Val) { + for _, o := range observers { + o.Observe(vars, id, programStep, value) + } + } +} + func (p *planBuilder) plan(expr ast.Expr) (InterpretableV2, error) { switch expr.Kind() { case ast.CallKind: diff --git a/interpreter/runtimecost.go b/interpreter/runtimecost.go index e9a7b4a70..1a364c941 100644 --- a/interpreter/runtimecost.go +++ b/interpreter/runtimecost.go @@ -16,9 +16,9 @@ package interpreter import ( "errors" - "math" "github.com/authzed/cel-go/common" + "github.com/authzed/cel-go/common/cost" "github.com/authzed/cel-go/common/overloads" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" @@ -57,7 +57,6 @@ func CostObserver(opts ...costTrackPlanOption) PlannerOption { return nil, errors.New("cost tracker factory not configured") } p.observers = append(p.observers, ct) - p.decorators = append(p.decorators, decObserveEval(ct.Observe)) return p, nil } } @@ -255,21 +254,21 @@ func (c *CostTracker) ActualCost() uint64 { } func (c *CostTracker) costCall(call InterpretableCall, args []ref.Val, result ref.Val) uint64 { - var cost uint64 + var total uint64 if len(c.overloadTrackers) != 0 { if tracker, found := c.overloadTrackers[call.OverloadID()]; found { callCost := tracker(args, result) if callCost != nil { - cost = safeAdd(cost, *callCost) - return cost + total = cost.SafeAdd(total, *callCost) + return total } } } if c.Estimator != nil { callCost := c.Estimator.CallCost(call.Function(), call.OverloadID(), args, result) if callCost != nil { - cost = safeAdd(cost, *callCost) - return cost + total = cost.SafeAdd(total, *callCost) + return total } } // if user didn't specify, the default way of calculating runtime cost would be used. @@ -277,13 +276,13 @@ func (c *CostTracker) costCall(call InterpretableCall, args []ref.Val, result re switch call.OverloadID() { // O(n) functions case overloads.StartsWithString, overloads.EndsWithString: - cost = safeAdd(cost, uint64(math.Ceil(float64(actualSize(args[1]))*common.StringTraversalCostFactor))) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(actualSize(args[1]), common.StringTraversalCostFactor)) case overloads.StringToBytes, overloads.BytesToString, overloads.ExtQuoteString, overloads.ExtFormatString: - cost = safeAdd(cost, uint64(math.Ceil(float64(actualSize(args[0]))*common.StringTraversalCostFactor))) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(actualSize(args[0]), common.StringTraversalCostFactor)) case overloads.InList: // If a list is composed entirely of constant values this is O(1), but we don't account for that here. // We just assume all list containment checks are O(n). - cost = safeAdd(cost, actualSize(args[1])) + total = cost.SafeAdd(total, actualSize(args[1])) // O(min(m, n)) functions case overloads.LessString, overloads.GreaterString, overloads.LessEqualsString, overloads.GreaterEqualsString, overloads.LessBytes, overloads.GreaterBytes, overloads.LessEqualsBytes, overloads.GreaterEqualsBytes, @@ -294,28 +293,29 @@ func (c *CostTracker) costCall(call InterpretableCall, args []ref.Val, result re lhsSize := actualSize(args[0]) rhsSize := actualSize(args[1]) minSize := min(rhsSize, lhsSize) - cost = safeAdd(cost, uint64(math.Ceil(float64(minSize)*common.StringTraversalCostFactor))) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(minSize, common.StringTraversalCostFactor)) // O(m+n) functions case overloads.AddString, overloads.AddBytes: // In the worst case scenario, we would need to reallocate a new backing store and copy both operands over. - cost = safeAdd(cost, uint64(math.Ceil(float64(actualSize(args[0])+actualSize(args[1]))*common.StringTraversalCostFactor))) + argSize := cost.SafeAdd(actualSize(args[0]), actualSize(args[1])) + total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(argSize, common.StringTraversalCostFactor)) // O(nm) functions case overloads.Matches, overloads.MatchesString: // https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL // Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0 // in case where string is empty but regex is still expensive. - strCost := uint64(math.Ceil((1.0 + float64(actualSize(args[0]))) * common.StringTraversalCostFactor)) + strCost := cost.SafeMultiplyByFactor(cost.SafeAdd(1, actualSize(args[0])), common.StringTraversalCostFactor) // We don't know how many expressions are in the regex, just the string length (a huge // improvement here would be to somehow get a count the number of expressions in the regex or // how many states are in the regex state machine and use that to measure regex cost). // For now, we're making a guess that each expression in a regex is typically at least 4 chars // in length. - regexCost := uint64(math.Ceil(float64(actualSize(args[1])) * common.RegexStringLengthCostFactor)) - cost = safeAdd(cost, strCost*regexCost) + regexCost := cost.SafeMultiplyByFactor(actualSize(args[1]), common.RegexStringLengthCostFactor) + total = cost.SafeAdd(total, cost.SafeMultiply(strCost, regexCost)) case overloads.ContainsString: - strCost := uint64(math.Ceil(float64(actualSize(args[0])) * common.StringTraversalCostFactor)) - substrCost := uint64(math.Ceil(float64(actualSize(args[1])) * common.StringTraversalCostFactor)) - cost = safeAdd(cost, strCost*substrCost) + strCost := cost.SafeMultiplyByFactor(actualSize(args[0]), common.StringTraversalCostFactor) + substrCost := cost.SafeMultiplyByFactor(actualSize(args[1]), common.StringTraversalCostFactor) + total = cost.SafeAdd(total, cost.SafeMultiply(strCost, substrCost)) default: // The following operations are assumed to have O(1) complexity. @@ -325,10 +325,10 @@ func (c *CostTracker) costCall(call InterpretableCall, args []ref.Val, result re // - Computing the size of strings, byte sequences, lists and maps. // - Logical operations and all operators on fixed width scalars (comparisons, equality) // - Any functions that don't have a declared cost either here or in provided ActualCostEstimator. - cost = safeAdd(cost, 1) + total = cost.SafeAdd(total, 1) } - return cost + return total } // actualSize returns the size of the value for all traits.Sizer values, a fixed size for all proto-based @@ -396,21 +396,3 @@ argloop: } return result, true } - -func safeAdd(x, y uint64, rest ...uint64) uint64 { - if y > 0 && x > math.MaxUint64-y { - return math.MaxUint64 - } - next := x + y - if len(rest) == 0 { - return next - } - return safeAdd(next, rest[0], rest[1:]...) -} - -func safeMul(x, y uint64) uint64 { - if y != 0 && x > math.MaxUint64/y { - return math.MaxUint64 - } - return x * y -} diff --git a/parser/BUILD.bazel b/parser/BUILD.bazel index 5726d2b5e..a4c722a88 100644 --- a/parser/BUILD.bazel +++ b/parser/BUILD.bazel @@ -48,6 +48,7 @@ go_test( deps = [ "//common/ast:go_default_library", "//common/debug:go_default_library", + "//common/operators:go_default_library", "//common/types:go_default_library", "//parser/gen:go_default_library", "//test:go_default_library", diff --git a/parser/parser_test.go b/parser/parser_test.go index 56c1fb36f..b01076e52 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -24,6 +24,7 @@ import ( "github.com/authzed/cel-go/common" "github.com/authzed/cel-go/common/ast" "github.com/authzed/cel-go/common/debug" + "github.com/authzed/cel-go/common/operators" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/test" ) @@ -2393,6 +2394,248 @@ func BenchmarkParseParallel(b *testing.B) { }) } +type benchTestInfo struct { + // I contains the input expression to be parsed. + I string + + // E indicates whether an error is expected. + E bool +} + +type benchCategory struct { + name string + cases []benchTestInfo +} + +var benchCategories = []benchCategory{ + // Simple: common, representative CEL expressions covering basic syntax, operators, calls, and literals + { + name: "Simple", + cases: []benchTestInfo{ + { + I: "x * 2 + y / 3", + }, + { + I: `foo.bar.baz(1, 2, "abc")`, + }, + { + I: `a > 5 && b < 10 || c == "xyz"`, + }, + { + I: "x ? y : z", + }, + { + I: `{"foo": 1, "bar": [2, 3]}`, + }, + { + I: "a[b]", + }, + { + I: "a.b.c", + }, + { + I: "a.`b-c`", + }, + { + I: "\"\\a\\b\\f\\n\\r\\t\\v'\\\"\\\\ Legal escapes \\u2764\"", + }, + }, + }, + + // Complex: expressions with deep chaining, nesting, precedence, and complex structures + { + name: "Complex", + cases: []benchTestInfo{ + { + I: "a" + strings.Repeat(" + a", 49), + }, + { + I: "a" + strings.Repeat(" || a", 49), + }, + { + I: "a" + strings.Repeat(".f", 49), + }, + { + I: strings.Repeat("(", 20) + "a" + strings.Repeat(")", 20), + }, + { + I: `SomeMessage{foo: 5, bar: "xyz"}`, + }, + { + I: "1 + 2 * 3 - 1 / 2 == 6 % 1", + }, + { + I: "[] + [1, 2, 3] + [4]", + }, + }, + }, + + // Macros: standard and receiver comprehension macros, optional syntax traversal + { + name: "Macros", + cases: []benchTestInfo{ + { + I: "has(m.f)", + }, + { + I: "[1, 2, 3].all(x, x > 0)", + }, + { + I: "m.map(v, v * 2)", + }, + { + I: "m.filter(v, v > 0)", + }, + { + I: "m.exists_one(v, v == 1)", + }, + { + I: "x.filter(y, y.exists(z, has(z.a)))", + }, + { + I: "a.?b[?0] && a[?c]", + }, + { + I: "m.optMap(v, v + 1)", + }, + }, + }, + + // Errors: representative syntax errors, invalid tokens, keywords, and unclosed delimiters + { + name: "Errors", + cases: []benchTestInfo{ + { + I: "x * 2 + y /", + E: true, + }, + { + I: `foo.bar.baz(1, 2, "abc"`, + E: true, + }, + { + I: "a > 5 && && b < 10", + E: true, + }, + { + I: `{"foo": 1, "bar": [2, 3`, + E: true, + }, + { + I: "1 + $", + E: true, + }, + { + I: "break", + E: true, + }, + { + I: `"\xFh"`, + E: true, + }, + { + I: "a" + strings.Repeat(" + a", 49) + " +", + E: true, + }, + { + I: strings.Repeat("(", 20) + "a", + E: true, + }, + { + I: "f(*" + strings.Repeat(", *", 9) + ")", + E: true, + }, + }, + }, +} + +// BenchmarkByCategory benchmarks parsing organized by workload categories. +func BenchmarkByCategory(b *testing.B) { + p := newBenchmarkCategoryParser(b) + for _, cat := range benchCategories { + b.Run(cat.name, func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, tc := range cat.cases { + src := common.NewTextSource(tc.I) + _, errs := p.Parse(src) + hasErr := len(errs.GetErrors()) > 0 + if hasErr != tc.E { + b.Fatalf("p.Parse(%q) got error: %v, expected error: %v", tc.I, hasErr, tc.E) + } + } + } + }) + } +} + +// BenchmarkParallelByCategory benchmarks parsing concurrently across goroutines by category. +func BenchmarkParallelByCategory(b *testing.B) { + p := newBenchmarkCategoryParser(b) + for _, cat := range benchCategories { + b.Run(cat.name, func(b *testing.B) { + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + for _, tc := range cat.cases { + src := common.NewTextSource(tc.I) + _, errs := p.Parse(src) + hasErr := len(errs.GetErrors()) > 0 + if hasErr != tc.E { + b.Fatalf("p.Parse(%q) got error: %v, expected error: %v", tc.I, hasErr, tc.E) + } + } + } + }) + }) + } +} + +// optMapMacro expands `m.optMap(v, f)` into a conditional comprehension. +var optMapMacro = NewReceiverMacro("optMap", 2, optMapExpander) + +func optMapExpander(meh ExprHelper, target ast.Expr, args []ast.Expr) (ast.Expr, *common.Error) { + varIdent := args[0] + varName := "" + switch varIdent.Kind() { + case ast.IdentKind: + varName = varIdent.AsIdent() + default: + return nil, meh.NewError(varIdent.ID(), "optMap() variable name must be a simple identifier") + } + mapExpr := args[1] + return meh.NewCall( + operators.Conditional, + meh.NewMemberCall("hasValue", target), + meh.NewCall("optional.of", + meh.NewComprehension( + meh.NewList(), + "#unused", + varName, + meh.NewMemberCall("value", meh.Copy(target)), + meh.NewLiteral(types.False), + meh.NewIdent(varName), + mapExpr, + ), + ), + meh.NewCall("optional.none"), + ), nil +} + +func newBenchmarkCategoryParser(tb testing.TB) *Parser { + tb.Helper() + p, err := NewParser( + Macros(append(AllMacros, optMapMacro)...), + EnableOptionalSyntax(true), + EnableIdentEscapeSyntax(true), + MaxRecursionDepth(512), + ) + if err != nil { + tb.Fatalf("NewParser() failed: %v", err) + } + return p +} + func TestParseErrorData(t *testing.T) { p := newTestParser(t) src := common.NewTextSource(`a.?b`) diff --git a/policy/BUILD.bazel b/policy/BUILD.bazel index 1f273238d..879dc5791 100644 --- a/policy/BUILD.bazel +++ b/policy/BUILD.bazel @@ -72,8 +72,9 @@ go_test( "//test:go_default_library", "//common/debug:go_default_library", "//common/types:go_default_library", - "//interpreter:go_default_library", "//common/types/ref:go_default_library", + "//common/types/traits:go_default_library", + "//interpreter:go_default_library", "//test/proto3pb:go_default_library", "@in_yaml_go_yaml_v3//:go_default_library", "@com_github_google_go_cmp//cmp:go_default_library", diff --git a/policy/compiler.go b/policy/compiler.go index 5d7507ac6..9fc599dc4 100644 --- a/policy/compiler.go +++ b/policy/compiler.go @@ -34,6 +34,7 @@ type CompiledRule struct { id *ValueString variables []*CompiledVariable matches []*CompiledMatch + semantic SemanticType } // SourceID returns the source metadata identifier associated with the compiled rule. @@ -56,11 +57,21 @@ func (r *CompiledRule) Matches() []*CompiledMatch { return r.matches[:] } +// Semantic returns the evaluation semantic for the compiled rule. +func (r *CompiledRule) Semantic() SemanticType { + return r.semantic +} + // OutputType returns the output type of the first match clause as all match clauses // are validated for agreement prior to construction fo the CompiledRule. func (r *CompiledRule) OutputType() *cel.Type { // It's a compilation error if the output types of the matches don't agree - for _, m := range r.Matches() { + matches := r.Matches() + if len(matches) > 0 { + m := matches[0] + if r.semantic == aggregate { + return cel.ListType(m.OutputType()) + } return m.OutputType() } return cel.DynType @@ -69,6 +80,9 @@ func (r *CompiledRule) OutputType() *cel.Type { // 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. func (r *CompiledRule) HasOptionalOutput() bool { + if r.semantic == aggregate { + return false + } optionalOutput := false for _, m := range r.Matches() { if m.NestedRule() != nil && m.NestedRule().HasOptionalOutput() { @@ -297,7 +311,7 @@ func CompileRule(env *cel.Env, p *Policy, opts ...CompilerOption) (*CompiledRule c.env = env } } - return c.compileRule(p.Rule(), p, c.env, iss) + return c.compileRule(p.Rule(), p, c.env, iss, false) } type compiler struct { @@ -310,7 +324,10 @@ type compiler struct { nestedCount int } -func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Issues) (*CompiledRule, *cel.Issues) { +func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Issues, hasAggregateAncestor bool) (*CompiledRule, *cel.Issues) { + if hasAggregateAncestor && r.semantic == aggregate { + iss.ReportErrorAtID(r.SourceID(), "nested aggregate rules are not allowed") + } compiledVars := make([]*CompiledVariable, len(r.Variables())) for i, v := range r.Variables() { exprSrc := c.relSource(v.Expression()) @@ -379,7 +396,8 @@ func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Is continue } if m.HasRule() { - nestedRule, ruleIss := c.compileRule(m.Rule(), p, ruleEnv, iss) + nextHasAggregateAncestor := hasAggregateAncestor || r.semantic == aggregate + nestedRule, ruleIss := c.compileRule(m.Rule(), p, ruleEnv, iss, nextHasAggregateAncestor) iss = iss.Append(ruleIss) compiledMatches = append(compiledMatches, &CompiledMatch{ exprID: m.exprID, @@ -401,6 +419,7 @@ func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Is id: r.id, variables: compiledVars, matches: compiledMatches, + semantic: r.semantic, } // Note: Consider supporting configurable policy validators that take the policy, rule, and issues @@ -453,10 +472,14 @@ func (c *compiler) checkUnreachableCode(rule *CompiledRule, iss *cel.Issues) { m := compiledMatches[i] triviallyTrue := m.ConditionIsLiteral(types.True) + if m.ConditionIsLiteral(types.False) { + iss.ReportErrorAtID(m.SourceID(), "Condition is always false") + } + // If the match is a single output or a nested rule that always returns a value, it is // exhaustive. If the condition is trivially true, then all subsequent branches are unreachable. isExhaustive := triviallyTrue && (m.NestedRule() == nil || !m.NestedRule().HasOptionalOutput()) - if isExhaustive && i != matchCount-1 { + if rule.semantic == firstMatch && isExhaustive && i != matchCount-1 { if m.Output() != nil { iss.ReportErrorAtID(m.SourceID(), "match creates unreachable outputs") } diff --git a/policy/compiler_test.go b/policy/compiler_test.go index fdb29aedc..1f6ea9016 100644 --- a/policy/compiler_test.go +++ b/policy/compiler_test.go @@ -46,52 +46,6 @@ func TestCompile(t *testing.T) { } } -func TestRuleComposerError(t *testing.T) { - env, err := cel.NewEnv() - if err != nil { - t.Fatalf("NewEnv() failed: %v", err) - } - _, err = NewRuleComposer(env, ExpressionUnnestHeight(-1)) - if err == nil || !strings.Contains(err.Error(), "invalid unnest") { - t.Errorf("NewRuleComposer() got %v, wanted 'invalid unnest'", err) - } -} - -func TestRuleComposerUnnest(t *testing.T) { - for _, tst := range composerUnnestTests { - tc := tst - t.Run(tc.name, func(t *testing.T) { - r := newRunner(tc.name, tc.expr, []ParserOption{}) - env, rule, iss := r.compileRule(t) - if iss.Err() != nil { - t.Fatalf("CompileRule() failed: %v", iss.Err()) - } - rc, err := NewRuleComposer(env, tc.composerOpts...) - if err != nil { - t.Fatalf("NewRuleComposer() failed: %v", err) - } - ast, iss := rc.Compose(rule) - if iss.Err() != nil { - t.Fatalf("Compose(rule) failed: %v", iss.Err()) - } - policy := parsePolicy(t, tc.name, []ParserOption{}) - verifySourceInfoCoverage(t, policy, ast) - unparsed, err := cel.AstToString(ast) - if err != nil { - t.Fatalf("cel.AstToString() failed: %v", err) - } - if normalize(unparsed) != normalize(tc.composed) { - t.Errorf("cel.AstToString() got %s, wanted %s", unparsed, tc.composed) - } - if !ast.OutputType().IsEquivalentType(tc.outputType) { - t.Errorf("ast.OutputType() got %v, wanted %v", ast.OutputType(), tc.outputType) - } - r.setup(t, env, ast) - r.run(t) - }) - } -} - func TestCompileError(t *testing.T) { for _, tst := range policyErrorTests { policy := parsePolicy(t, tst.name, []ParserOption{}) @@ -290,16 +244,48 @@ func BenchmarkCompile(b *testing.B) { } } -func newRunner(name, expr string, parseOpts []ParserOption, opts ...cel.EnvOption) *runner { +func parsePolicySource(t testing.TB, name string, policySource string, parseOpts ...ParserOption) *Policy { + t.Helper() + p := StringSource(policySource, name) + parser, err := NewParser(parseOpts...) + if err != nil { + t.Fatalf("NewParser() failed: %v", err) + } + policy, iss := parser.Parse(p) + if iss.Err() != nil { + t.Fatalf("parser.Parse() failed: %v", iss.Err()) + } + return policy +} + +func parseAndCompilePolicy(t testing.TB, name string, policySource string, envOpts []cel.EnvOption, compilerOpts []CompilerOption) (*cel.Env, *cel.Ast, *cel.Issues) { + t.Helper() + policy := parsePolicySource(t, name, policySource) + envOpts = append([]cel.EnvOption{ + cel.OptionalTypes(), + cel.EnableMacroCallTracking(), + ext.Bindings(), + }, envOpts...) + env, err := cel.NewEnv(envOpts...) + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + ast, iss := Compile(env, policy, compilerOpts...) + return env, ast, iss +} + +func newRunner(name, expr string, parseOpts []ParserOption, envOpts ...cel.EnvOption) *runner { return &runner{ name: name, parseOpts: parseOpts, + envOpts: envOpts, expr: expr} } type runner struct { name string parseOpts []ParserOption + envOpts []cel.EnvOption env *cel.Env expr string prg cel.Program @@ -322,6 +308,12 @@ func (r *runner) compileRule(t testing.TB) (*cel.Env, *CompiledRule, *cel.Issues if err != nil { t.Fatalf("cel.NewEnv() failed: %v", err) } + if len(r.envOpts) > 0 { + env, err = env.Extend(r.envOpts...) + if err != nil { + t.Fatalf("env.Extend() with env options failed: %v", err) + } + } // Configure declarations env, err = env.Extend(FromConfig(config)) if err != nil { @@ -605,7 +597,9 @@ func exprLinesFromPolicy(policy *Policy) map[int]bool { addExpectedLines(v.Expression()) } for _, m := range r.Matches() { - addExpectedLines(m.Condition()) + if !strings.HasPrefix(m.Condition().Value, "true") { + addExpectedLines(m.Condition()) + } if m.HasOutput() { addExpectedLines(m.Output()) } @@ -617,3 +611,238 @@ func exprLinesFromPolicy(policy *Policy) map[int]bool { traverseRule(policy.Rule()) return lines } + +func TestCompileYAMLPolicy_Aggregate(t *testing.T) { + type testEval struct { + input map[string]any + output ref.Val + } + tests := []struct { + name string + policy string + envOpts []cel.EnvOption + expectedUnparsed string + evals []testEval + wantErr string + }{ + { + name: "eval_aggregate", + policy: `name: "aggregate_policy" +rule: + aggregate: + - condition: 'true' + output: '"PII"' + - condition: 'true' + output: '"CONFIDENTIAL"'`, + expectedUnparsed: `["PII"] + ["CONFIDENTIAL"]`, + evals: []testEval{ + { + input: map[string]any{}, + output: types.NewStringList(types.DefaultTypeAdapter, []string{"PII", "CONFIDENTIAL"}), + }, + }, + }, + { + name: "aggregate_with_block_variables", + policy: `name: "block_policy" +rule: + variables: + - name: val1 + expression: '"PII"' + - name: val2 + expression: '"CONFIDENTIAL"' + aggregate: + - condition: 'true' + output: 'variables.val1' + - condition: 'true' + output: 'variables.val2'`, + expectedUnparsed: `cel.@block(["PII", "CONFIDENTIAL"], [@index0] + [@index1])`, + evals: []testEval{ + { + input: map[string]any{}, + output: types.NewStringList(types.DefaultTypeAdapter, []string{"PII", "CONFIDENTIAL"}), + }, + }, + }, + { + name: "aggregate_conditions_and_block_variables", + policy: `name: "cse_policy" +rule: + variables: + - name: threshold + expression: "5" + aggregate: + - condition: "size(resource.payload) > variables.threshold" + output: '"CSE1"' + - condition: "size(resource.payload) > variables.threshold" + output: '"CSE2"' + - condition: 'true' + output: '"ALWAYS"'`, + envOpts: []cel.EnvOption{ + cel.Variable("resource", cel.MapType(cel.StringType, cel.ListType(cel.IntType))), + }, + expectedUnparsed: `cel.@block([5], ((size(resource.payload) > @index0) ? ["CSE1"] : []) + (((size(resource.payload) > @index0) ? ["CSE2"] : []) + ["ALWAYS"]))`, + evals: []testEval{ + { + input: map[string]any{ + "resource": map[string]any{ + "payload": []int64{1, 2, 3, 4, 5, 6}, + }, + }, + output: types.NewStringList(types.DefaultTypeAdapter, []string{"CSE1", "CSE2", "ALWAYS"}), + }, + { + input: map[string]any{ + "resource": map[string]any{ + "payload": []int64{1, 2, 3}, + }, + }, + output: types.NewStringList(types.DefaultTypeAdapter, []string{"ALWAYS"}), + }, + }, + }, + { + name: "aggregate_macros_preserved", + policy: `name: aggregate_macros_preserved +rule: + variables: + - name: min_val + expression: "10" + aggregate: + - condition: "cond" + rule: + match: + - condition: "true" + output: "payload.filter(x, x > variables.min_val).exists(y, y % 2 == 0)" + - condition: "true" + output: "payload.all(x, x > 0)"`, + envOpts: []cel.EnvOption{ + cel.Variable("cond", cel.BoolType), + cel.Variable("payload", cel.ListType(cel.IntType)), + }, + expectedUnparsed: `cel.@block([10], (cond ? [payload.filter(x, x > @index0).exists(y, y % 2 == 0)] : []) + [payload.all(x, x > 0)])`, + }, + { + name: "nested_aggregate_throws", + policy: `name: nested_aggregate +rule: + aggregate: + - condition: 'true' + rule: + aggregate: + - condition: 'true' + output: "'foo'"`, + wantErr: "nested aggregate rules are not allowed", + }, + { + name: "nested_aggregate_with_match_throws", + policy: `name: nested_aggregate_with_match +rule: + aggregate: + - condition: 'true' + rule: + match: + - condition: 'true' + rule: + aggregate: + - condition: 'true' + output: "'foo'"`, + wantErr: "nested aggregate rules are not allowed", + }, + { + name: "aggregate_under_match_success", + policy: `name: aggregate_under_match +rule: + match: + - condition: 'true' + rule: + aggregate: + - condition: 'true' + output: "'foo'"`, + expectedUnparsed: `["foo"]`, + }, + } + + for _, tst := range tests { + tc := tst + t.Run(tc.name, func(t *testing.T) { + env, ast, iss := parseAndCompilePolicy(t, tc.name, tc.policy, tc.envOpts, nil) + if tc.wantErr != "" { + if iss.Err() == nil { + t.Fatalf("Compile() succeeded, wanted error %q", tc.wantErr) + } + if !strings.Contains(iss.Err().Error(), tc.wantErr) { + t.Errorf("Compile() got %v, wanted error containing %q", iss.Err(), tc.wantErr) + } + return + } + + if iss.Err() != nil { + t.Fatalf("Compile() failed: %v", iss.Err()) + } + + unparsed, err := cel.AstToString(ast) + if err != nil { + t.Fatalf("cel.AstToString() failed: %v", err) + } + if tc.expectedUnparsed != "" && normalize(unparsed) != normalize(tc.expectedUnparsed) { + t.Errorf("cel.AstToString() got %s, wanted %s", unparsed, tc.expectedUnparsed) + } + + _, err = cel.AstToCheckedExpr(ast) + if err != nil { + t.Fatalf("cel.AstToCheckedExpr() failed: %v", err) + } + + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("env.Program(ast) failed: %v", err) + } + + for _, ev := range tc.evals { + out, _, err := prg.Eval(ev.input) + if err != nil { + t.Fatalf("prg.Eval(%v) failed: %v", ev.input, err) + } + if out.Equal(ev.output) != types.True { + t.Errorf("prg.Eval(%v) got %v, wanted %v", ev.input, out, ev.output) + } + } + }) + } +} + +func TestCompiledRuleSemantic(t *testing.T) { + policySource := `name: aggregate_semantic +rule: + aggregate: + - condition: 'true' + output: "'foo'"` + policy := parsePolicySource(t, "aggregate_semantic", policySource) + env, err := cel.NewEnv() + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + compiledRule, iss := CompileRule(env, policy) + if iss.Err() != nil { + t.Fatalf("CompileRule() failed: %v", iss.Err()) + } + if compiledRule.Semantic() != aggregate { + t.Errorf("got %v, wanted aggregate", compiledRule.Semantic()) + } +} + +func TestCompileYAMLPolicy_ConditionAlwaysFalse(t *testing.T) { + policySource := `name: condition_always_false +rule: + aggregate: + - condition: 'false' + output: "'foo'"` + _, _, iss := parseAndCompilePolicy(t, "condition_always_false", policySource, nil, nil) + if iss.Err() == nil { + t.Fatalf("Compile() succeeded, wanted error") + } + if !strings.Contains(iss.Err().Error(), "Condition is always false") { + t.Errorf("Compile() got %v, wanted 'Condition is always false'", iss.Err()) + } +} diff --git a/policy/composer.go b/policy/composer.go index 5e993957f..a05da2144 100644 --- a/policy/composer.go +++ b/policy/composer.go @@ -92,7 +92,7 @@ func (c *RuleComposer) Compose(r *CompiledRule) (*cel.Ast, *cel.Issues) { return nil, iss } unnester := &ruleUnnesterImpl{ - nextVarIndex: len(composer.varIndices), + nextVarIndex: len(composer.varIndices), varIndices: composer.varIndices, exprUnnestHeight: c.exprUnnestHeight, } @@ -159,7 +159,7 @@ func (opt *ruleComposerImpl) exitScope() { func (opt *ruleComposerImpl) Optimize(ctx *cel.OptimizerContext, a *ast.AST) *ast.AST { // The input to optimize is a dummy expression which is completely replaced according // to the configuration of the rule composition graph. - ruleExpr := opt.optimizeRule(ctx, opt.rule) + ruleExpr := opt.optimizeRule(ctx, opt.rule, false) // If there were no variables, return the expression. if len(opt.varIndices) == 0 { @@ -180,7 +180,7 @@ func (opt *ruleComposerImpl) Optimize(ctx *cel.OptimizerContext, a *ast.AST) *as return ctx.NewAST(blockExpr) } -func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *CompiledRule) ast.Expr { +func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *CompiledRule, asList bool) ast.Expr { // Visitor to rewrite variables-prefixed identifiers with index names. opt.enterScope() defer opt.exitScope() @@ -189,43 +189,59 @@ func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *Compiled opt.registerVariable(ctx, v) } + isAggregate := r.semantic == aggregate + returnList := isAggregate || asList + matches := r.Matches() matchCount := len(matches) - var output compositionStep = nil - // 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 r.HasOptionalOutput() { - output = newOptionalCompositionStep(ctx, ctx.NewLiteral(types.True), ctx.NewCall("optional.none")) - } + output := opt.createBaseStep(ctx, returnList, r.HasOptionalOutput()) + // Build the rule subgraph. for i := matchCount - 1; i >= 0; i-- { m := matches[i] cond := ctx.CopyASTAndMetadata(m.Condition().NativeRep()) - // If the output is non-nil, then it is considered a non-optional output since - // it is explictly 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. + var currentStep compositionStep if m.Output() != nil { + // If the output is non-nil, 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. out := ctx.CopyASTAndMetadata(m.Output().Expr().NativeRep()) - step := newNonOptionalCompositionStep(ctx, cond, out) - output = step.combine(output) - continue + if returnList { + out = ctx.NewList([]ast.Expr{out}, []int32{}) + } + currentStep = newNonOptionalCompositionStep(ctx, cond, out) + + } else if m.NestedRule() != nil { + // If the match has a nested rule, then compute the rule and whether it has + // an optional return value. + // + // Semantics for nesting: + // - With optional values (nestedHasOptional = true): The step is treated as optional. + // If the nested rule yields optional.none, composition allows fall-through to + // subsequent match cases. + // - Without optional values (nestedHasOptional = false): The step is treated as non-optional. + // A matching result produces a concrete value that short-circuits further match evaluation, + // though it may be wrapped into optional.of(...) if the outer rule produces optional output. + child := m.NestedRule() + nestedRule := opt.optimizeRule(ctx, child, returnList) + if child.HasOptionalOutput() { + currentStep = newOptionalCompositionStep(ctx, cond, nestedRule) + } else { + currentStep = newNonOptionalCompositionStep(ctx, cond, nestedRule) + } + } else { + // Report an error for an unknown rule kind: + ctx.ReportErrorAtID(cond.ID(), "unknown match kind: %v", m.SourceID()) + return nil } - // If the match has a nested rule, then compute the rule and whether it has - // an optional return value. - child := m.NestedRule() - nestedRule := opt.optimizeRule(ctx, child) - nestedHasOptional := child.HasOptionalOutput() - if nestedHasOptional { - step := newOptionalCompositionStep(ctx, cond, nestedRule) - output = step.combine(output) - continue + if isAggregate { + output = opt.combineAggregate(ctx, currentStep, output) + } else { + output = currentStep.combine(output) } - step := newNonOptionalCompositionStep(ctx, cond, nestedRule) - output = step.combine(output) } matchExpr := output.expr() @@ -235,6 +251,34 @@ func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *Compiled return matchExpr } +func (opt *ruleComposerImpl) createBaseStep(ctx *cel.OptimizerContext, returnList, hasOptionalOutput bool) compositionStep { + if returnList { + return newNonOptionalCompositionStep(ctx, ctx.NewLiteral(types.True), ctx.NewList([]ast.Expr{}, []int32{})) + } + if hasOptionalOutput { + return newOptionalCompositionStep(ctx, ctx.NewLiteral(types.True), ctx.NewCall("optional.none")) + } + return nil +} + +func (opt *ruleComposerImpl) combineAggregate(ctx *cel.OptimizerContext, step, accumulatedStep compositionStep) compositionStep { + trueCondition := ctx.NewLiteral(types.True) + currentListPart := step.expr() + var conditionalListPart ast.Expr + if step.isConditional() { + emptyList := ctx.NewList([]ast.Expr{}, []int32{}) + conditionalListPart = ctx.NewCall(operators.Conditional, step.condition(), currentListPart, emptyList) + } else { + conditionalListPart = currentListPart + } + + if accumulatedStep.expr().Kind() == ast.ListKind && len(accumulatedStep.expr().AsList().Elements()) == 0 { + return newNonOptionalCompositionStep(ctx, trueCondition, conditionalListPart) + } + concatenated := ctx.NewCall(operators.Add, conditionalListPart, accumulatedStep.expr()) + return newNonOptionalCompositionStep(ctx, trueCondition, concatenated) +} + func (opt *ruleComposerImpl) rewriteVariableName(ctx *cel.OptimizerContext) ast.Visitor { return ast.NewExprVisitor(func(expr ast.Expr) { if expr.Kind() != ast.IdentKind || !strings.HasPrefix(expr.AsIdent(), "variables.") { @@ -265,7 +309,7 @@ func (opt *ruleComposerImpl) registerVariable(ctx *cel.OptimizerContext, v *Comp celType: v.Declaration().Type()} opt.varIndices = append(opt.varIndices, vi) if len(opt.scopes) > 0 { - opt.scopes[len(opt.scopes) - 1][varName] = len(opt.varIndices) - 1 + opt.scopes[len(opt.scopes)-1][varName] = len(opt.varIndices) - 1 } opt.nextVarIndex++ } @@ -494,6 +538,9 @@ func (s nonOptionalCompositionStep) combine(step compositionStep) compositionSte // Likely a candidate for dead-code warnings. return s } + if !s.isConditional() { + return s + } return newNonOptionalCompositionStep(ctx, trueCondition, ctx.NewCall(operators.Conditional, @@ -587,9 +634,7 @@ func isOptionalNone(e ast.Expr) bool { func removeIneligibleSubExprs(e ast.NavigableExpr, unnestMap map[int64]bool) { for _, id := range comprehensionSubExprIDs(e) { - if _, found := unnestMap[id]; found { - delete(unnestMap, id) - } + delete(unnestMap, id) } } diff --git a/policy/composer_test.go b/policy/composer_test.go index 8a158fdf2..db0334bbf 100644 --- a/policy/composer_test.go +++ b/policy/composer_test.go @@ -1,97 +1,204 @@ package policy import ( + "fmt" "strings" "testing" "github.com/authzed/cel-go/cel" "github.com/authzed/cel-go/common/ast" "github.com/authzed/cel-go/common/debug" + "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/ext" ) -func TestCompose_SourceInfo(t *testing.T) { - policyYAML := `name: test_policy +func TestCompose(t *testing.T) { + tests := []struct { + name string + policy string + composerOpts []ComposerOption + wantUnparsed string + wantEval string + checkInfo bool + }{ + { + name: "source_info", + policy: `name: test_policy rule: match: - condition: "2 == 1" output: "'hi'" - output: "'hello' + ' world'" -` - src := StringSource(policyYAML, "test_policy.yaml") - parser, err := NewParser() - if err != nil { - t.Fatalf("NewParser() failed: %v", err) - } - policy, iss := parser.Parse(src) - if iss.Err() != nil { - t.Fatalf("parser.Parse() failed: %v", iss.Err()) - } - - env, err := cel.NewEnv(cel.OptionalTypes(), ext.Bindings()) - if err != nil { - t.Fatalf("cel.NewEnv() failed: %v", err) - } - compiledRule, iss := CompileRule(env, policy) - if iss.Err() != nil { - t.Fatalf("CompileRule() failed: %v", iss.Err()) - } - composer, err := NewRuleComposer(env) - if err != nil { - t.Fatalf("NewRuleComposer() failed: %v", err) - } - compAST, iss := composer.Compose(compiledRule) - if iss.Err() != nil { - t.Fatalf("composer.Compose() failed: %v", iss.Err()) - } - - si := compAST.SourceInfo() - if si.Location != "test_policy.yaml" { - t.Errorf("SourceInfo.Location got %q, wanted test_policy.yaml", si.Location) - } - verifySourceInfoTransfer(t, compiledRule, compAST) -} - -func TestCompose_Unnest(t *testing.T) { - policyYAML := `name: unnest +`, + checkInfo: true, + }, + { + name: "unnest", + policy: `name: unnest rule: match: - condition: "2 == 1" output: "'hi'" - output: "'hello'" -` - src := StringSource(policyYAML, "unnest.yaml") - parser, err := NewParser() - if err != nil { - t.Fatalf("NewParser() failed: %v", err) +`, + composerOpts: []ComposerOption{ExpressionUnnestHeight(1)}, + checkInfo: true, + }, + { + name: "empty_aggregate", + policy: `name: empty_nested_match_under_aggregate +rule: + aggregate: + - condition: "true" + rule: + match: [] +`, + wantUnparsed: "[]", + wantEval: "[]", + }, + { + name: "conditional_optional_nested", + policy: `name: conditional_optional_nested +rule: + match: + - condition: "2 == 2" + rule: + match: + - condition: "1 == 1" + output: "'foo'" + - condition: "true" + rule: + match: + - condition: "3 == 3" + output: "'bar'" +`, + wantUnparsed: `(2 == 2) ? ((1 == 1) ? optional.of("foo") : optional.none()) : ((3 == 3) ? optional.of("bar") : optional.none())`, + wantEval: `foo`, + }, } - policy, iss := parser.Parse(src) - if iss.Err() != nil { - t.Fatalf("parser.Parse() failed: %v", iss.Err()) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + env, compiledRule, compAST := parseAndComposeRule(t, tc.policy, tc.name+".yaml", tc.composerOpts...) + if tc.checkInfo { + si := compAST.SourceInfo() + if si.Location != tc.name+".yaml" { + t.Errorf("SourceInfo.Location got %q, wanted %s.yaml", si.Location, tc.name) + } + verifySourceInfoTransfer(t, compiledRule, compAST) + if t.Failed() { + t.Logf("composed AST: %s", debug.ToDebugStringWithIDs(compAST.NativeRep().Expr())) + t.Logf("SourceInfo: %v", compAST.NativeRep().SourceInfo().OffsetRanges()) + } + } + if tc.wantUnparsed != "" { + exprStr, err := cel.AstToString(compAST) + if err != nil { + t.Fatalf("cel.AstToString() failed: %v", err) + } + if normalize(exprStr) != normalize(tc.wantUnparsed) { + t.Errorf("cel.AstToString() got %q, wanted %q", exprStr, tc.wantUnparsed) + } + } + if tc.wantEval != "" { + prg, err := env.Program(compAST) + if err != nil { + t.Fatalf("env.Program() failed: %v", err) + } + res, _, err := prg.Eval(cel.NoVars()) + if err != nil { + t.Fatalf("prg.Eval() failed: %v", err) + } + if fmt.Sprintf("%v", res.Value()) != tc.wantEval { + t.Errorf("eval result got %v, wanted %s", res.Value(), tc.wantEval) + } + } + }) } +} - env, err := cel.NewEnv(cel.OptionalTypes(), ext.Bindings()) +type testUnconditionalComposer struct{} + +func (t testUnconditionalComposer) Optimize(ctx *cel.OptimizerContext, a *ast.AST) *ast.AST { + trueCond := ctx.NewLiteral(types.True) + out1 := ctx.NewLiteral(types.String("first")) + out2 := ctx.NewLiteral(types.String("second")) + + s := newNonOptionalCompositionStep(ctx, trueCond, out1) + step := newNonOptionalCompositionStep(ctx, trueCond, out2) + + combined := s.combine(step) + return ctx.NewAST(combined.expr()) +} + +// Note: This test case cannot be reached through the policy format (because the compiler +// statically rejects policies with unreachable outputs), but is expressed in code for defense in depth. +func TestNonOptionalCompositionStep_UnconditionalCombine(t *testing.T) { + env, err := cel.NewEnv() if err != nil { t.Fatalf("cel.NewEnv() failed: %v", err) } - compiledRule, iss := CompileRule(env, policy) + opt, err := cel.NewStaticOptimizer(testUnconditionalComposer{}) + if err != nil { + t.Fatalf("cel.NewStaticOptimizer() failed: %v", err) + } + dummyAST, _ := env.Compile("true") + resultAST, iss := opt.Optimize(env, dummyAST) if iss.Err() != nil { - t.Fatalf("CompileRule() failed: %v", iss.Err()) + t.Fatalf("Optimize() failed: %v", iss.Err()) } + exprStr, err := cel.AstToString(resultAST) + if err != nil { + t.Fatalf("cel.AstToString() failed: %v", err) + } + if exprStr != `"first"` { + t.Errorf("got %q, wanted \"first\"", exprStr) + } +} - composer, err := NewRuleComposer(env, ExpressionUnnestHeight(1)) +func TestRuleComposerError(t *testing.T) { + env, err := cel.NewEnv() if err != nil { - t.Fatalf("NewRuleComposer() failed: %v", err) + t.Fatalf("NewEnv() failed: %v", err) } - compAST, iss := composer.Compose(compiledRule) - if iss.Err() != nil { - t.Fatalf("composer.Compose() failed: %v", iss.Err()) + _, err = NewRuleComposer(env, ExpressionUnnestHeight(-1)) + if err == nil || !strings.Contains(err.Error(), "invalid unnest") { + t.Errorf("NewRuleComposer() got %v, wanted 'invalid unnest'", err) } +} - verifySourceInfoTransfer(t, compiledRule, compAST) - if t.Failed() { - t.Logf("composed AST: %s", debug.ToDebugStringWithIDs(compAST.NativeRep().Expr())) - t.Logf("SourceInfo: %v", compAST.NativeRep().SourceInfo().OffsetRanges()) +func TestRuleComposerUnnest(t *testing.T) { + for _, tst := range composerUnnestTests { + tc := tst + t.Run(tc.name, func(t *testing.T) { + r := newRunner(tc.name, tc.expr, []ParserOption{}, tc.envOpts...) + env, rule, iss := r.compileRule(t) + if iss.Err() != nil { + t.Fatalf("CompileRule() failed: %v", iss.Err()) + } + rc, err := NewRuleComposer(env, tc.composerOpts...) + if err != nil { + t.Fatalf("NewRuleComposer() failed: %v", err) + } + ast, iss := rc.Compose(rule) + if iss.Err() != nil { + t.Fatalf("Compose(rule) failed: %v", iss.Err()) + } + policy := parsePolicy(t, tc.name, []ParserOption{}) + verifySourceInfoCoverage(t, policy, ast) + unparsed, err := cel.AstToString(ast) + if err != nil { + t.Fatalf("cel.AstToString() failed: %v", err) + } + if normalize(unparsed) != normalize(tc.composed) { + t.Errorf("cel.AstToString() got %s, wanted %s", unparsed, tc.composed) + } + if !ast.OutputType().IsEquivalentType(tc.outputType) { + t.Errorf("ast.OutputType() got %v, wanted %v", ast.OutputType(), tc.outputType) + } + r.setup(t, env, ast) + r.run(t) + }) } } @@ -107,9 +214,16 @@ func verifySourceInfoTransfer(t *testing.T, compiledRule *CompiledRule, composed ranges: &dstRanges}) } ast.PostOrderVisit(composed.NativeRep().Expr(), &collectRanges{sourceInfo: composed.NativeRep().SourceInfo(), ranges: &dstRanges}) + for _, v := range compiledRule.variables { + check(v.expr) + } for _, match := range compiledRule.matches { check(match.cond) - check(match.output.expr) + if match.output != nil { + check(match.output.expr) + } else if match.nestedRule != nil { + verifySourceInfoTransfer(t, match.nestedRule, composed) + } } } @@ -169,3 +283,33 @@ func (c *transferChecker) VisitExpr(srcExpr ast.Expr) { func (c *transferChecker) VisitEntryExpr(ast.EntryExpr) { } + +func parseAndComposeRule(t testing.TB, policyYAML, filename string, composerOpts ...ComposerOption) (*cel.Env, *CompiledRule, *cel.Ast) { + t.Helper() + src := StringSource(policyYAML, filename) + parser, err := NewParser() + if err != nil { + t.Fatalf("NewParser() failed: %v", err) + } + policy, iss := parser.Parse(src) + if iss.Err() != nil { + t.Fatalf("parser.Parse() failed: %v", iss.Err()) + } + env, err := cel.NewEnv(cel.OptionalTypes(), ext.Bindings()) + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + compiledRule, iss := CompileRule(env, policy) + if iss.Err() != nil { + t.Fatalf("CompileRule() failed: %v", iss.Err()) + } + composer, err := NewRuleComposer(env, composerOpts...) + if err != nil { + t.Fatalf("NewRuleComposer() failed: %v", err) + } + compAST, iss := composer.Compose(compiledRule) + if iss.Err() != nil { + t.Fatalf("composer.Compose() failed: %v", iss.Err()) + } + return env, compiledRule, compAST +} diff --git a/policy/config_test.go b/policy/config_test.go index 40bce9775..7c8264d27 100644 --- a/policy/config_test.go +++ b/policy/config_test.go @@ -102,7 +102,7 @@ variables: t.Fatalf("cel.NewEnv() failed: %v", err) } for _, tst := range tests { - c := parseConfigYaml(t, tst) + c := parseConfigYAML(t, tst) _, err := baseEnv.Extend(FromConfig(c)) if err != nil { t.Errorf("AsEnvOptions() generated error: %v", err) @@ -233,7 +233,7 @@ functions: t.Fatalf("cel.NewEnv() failed: %v", err) } for _, tst := range tests { - c := parseConfigYaml(t, tst.config) + c := parseConfigYAML(t, tst.config) _, err := baseEnv.Extend(FromConfig(c)) if err == nil || err.Error() != tst.err { t.Errorf("AsEnvOptions() got error: %v, wanted %s", err, tst.err) @@ -241,7 +241,7 @@ functions: } } -func parseConfigYaml(t *testing.T, doc string) *env.Config { +func parseConfigYAML(t *testing.T, doc string) *env.Config { config := &env.Config{} if err := yaml.Unmarshal([]byte(doc), config); err != nil { t.Fatalf("yaml.Unmarshal(%q) failed: %v", doc, err) diff --git a/policy/helper_test.go b/policy/helper_test.go index 5c815d0ae..53ef3762d 100644 --- a/policy/helper_test.go +++ b/policy/helper_test.go @@ -23,10 +23,10 @@ import ( "github.com/authzed/cel-go/common/env" "github.com/authzed/cel-go/common/types" "github.com/authzed/cel-go/common/types/ref" + "github.com/authzed/cel-go/common/types/traits" "github.com/authzed/cel-go/test" "go.yaml.in/yaml/v3" - ) var ( @@ -129,6 +129,28 @@ var ( ? optional.of(((y == 1) ? optional.of("a") : optional.none()).orValue("b")) : optional.none()`, }, + { + name: "agent_tool_execution_governance", + expr: `(request.is_emergency ? ["REQUIRE_VP_APPROVAL"] : ((tool.is_mutation && request.env == "prod") ? ["REQUIRE_TECH_LEAD_2FA"] : (tool.is_mutation ? ["REQUIRE_PEER_CONFIRMATION"] : []))) + ((hasCreditCard(tool.call.args) ? ["REDACT_PCI"] : (hasEmailOrPhone(tool.call.args) ? ["REDACT_PII"] : [])) + ((tool.call.args.batch_size > 10000) ? ["THROTTLE_TIER_3"] : ((tool.call.args.batch_size > 1000) ? ["THROTTLE_TIER_2"] : ((tool.call.args.batch_size > 100) ? ["THROTTLE_TIER_1"] : []))))`, + envOpts: []cel.EnvOption{ + cel.Function("hasCreditCard", + cel.Overload("hasCreditCard", []*cel.Type{cel.DynType}, cel.BoolType, + cel.UnaryBinding(func(args ref.Val) ref.Val { + if m, ok := args.(traits.Mapper); ok { + return types.Bool(m.Contains(types.String("cc")) == types.True) + } + return types.False + }))), + cel.Function("hasEmailOrPhone", + cel.Overload("hasEmailOrPhone", []*cel.Type{cel.DynType}, cel.BoolType, + cel.UnaryBinding(func(args ref.Val) ref.Val { + if m, ok := args.(traits.Mapper); ok { + return types.Bool(m.Contains(types.String("email")) == types.True || m.Contains(types.String("phone")) == types.True) + } + return types.False + }))), + }, + }, } composerUnnestTests = []struct { @@ -136,6 +158,7 @@ var ( expr string composed string composerOpts []ComposerOption + envOpts []cel.EnvOption outputType *cel.Type }{ { @@ -209,6 +232,30 @@ var ( (now.getHours() >= 20) ? @index5 : optional.of(@index3.format([@index0, @index2])))`, outputType: cel.OptionalType(cel.StringType), }, + { + name: "agent_tool_execution_governance", + composerOpts: []ComposerOption{ExpressionUnnestHeight(2)}, + envOpts: []cel.EnvOption{ + cel.Function("hasCreditCard", + cel.Overload("hasCreditCard", []*cel.Type{cel.DynType}, cel.BoolType, + cel.UnaryBinding(func(args ref.Val) ref.Val { + if m, ok := args.(traits.Mapper); ok { + return types.Bool(m.Contains(types.String("cc")) == types.True) + } + return types.False + }))), + cel.Function("hasEmailOrPhone", + cel.Overload("hasEmailOrPhone", []*cel.Type{cel.DynType}, cel.BoolType, + cel.UnaryBinding(func(args ref.Val) ref.Val { + if m, ok := args.(traits.Mapper); ok { + return types.Bool(m.Contains(types.String("email")) == types.True || m.Contains(types.String("phone")) == types.True) + } + return types.False + }))), + }, + composed: `cel.@block([tool.is_mutation && request.env == "prod", tool.is_mutation ? ["REQUIRE_PEER_CONFIRMATION"] : [], hasEmailOrPhone(tool.call.args) ? ["REDACT_PII"] : [], tool.call.args.batch_size > 10000, tool.call.args.batch_size > 1000, tool.call.args.batch_size > 100, request.is_emergency ? ["REQUIRE_VP_APPROVAL"] : (@index0 ? ["REQUIRE_TECH_LEAD_2FA"] : @index1)], @index6 + ((hasCreditCard(tool.call.args) ? ["REDACT_PCI"] : @index2) + (@index3 ? ["THROTTLE_TIER_3"] : (@index4 ? ["THROTTLE_TIER_2"] : (@index5 ? ["THROTTLE_TIER_1"] : [])))))`, + outputType: cel.ListType(cel.StringType), + }, } policyErrorTests = []struct { @@ -270,6 +317,9 @@ ERROR: testdata/errors/policy.yaml:45:16: incompatible output types: block has o | ........^ ERROR: testdata/errors_unreachable/policy.yaml:36:13: match creates unreachable outputs | - output: | + | ............^ +ERROR: testdata/errors_unreachable/policy.yaml:38:13: Condition is always false + | - condition: "false" | ............^`, }, { @@ -278,6 +328,30 @@ ERROR: testdata/errors_unreachable/policy.yaml:36:13: match creates unreachable | match: | ........^`, }, + { + name: "aggregate_errors", + err: `ERROR: testdata/aggregate_errors/policy.yaml:21:13: match creates unreachable outputs + | - condition: "true" + | ............^ +ERROR: testdata/aggregate_errors/policy.yaml:24:22: incompatible output types: block has output type int, but previous outputs have type optional_type(string) + | output: "403" + | .....................^`, + }, + { + name: "aggregate_list_errors", + err: `ERROR: testdata/aggregate_list_errors/policy.yaml:21:13: match creates unreachable outputs + | - condition: "true" + | ............^ +ERROR: testdata/aggregate_list_errors/policy.yaml:24:22: incompatible output types: block has output type int, but previous outputs have type list(string) + | output: "403" + | .....................^`, + }, + { + name: "aggregate_nested_mixed_semantics", + err: `ERROR: testdata/aggregate_nested_mixed_semantics/policy.yaml:23:15: nested aggregate rules are not allowed + | aggregate: + | ..............^`, + }, } ) diff --git a/policy/parser.go b/policy/parser.go index 7682ddafe..d91abbc1e 100644 --- a/policy/parser.go +++ b/policy/parser.go @@ -25,11 +25,13 @@ import ( "github.com/authzed/cel-go/common/ast" ) -type semanticType int +// SemanticType describes the evaluation semantic for a given policy block. +type SemanticType int const ( - unspecified semanticType = iota + unspecified SemanticType = iota firstMatch + aggregate ) // NewPolicy creates a policy object which references a policy source and source information. @@ -38,7 +40,7 @@ func NewPolicy(src *Source, info *ast.SourceInfo) *Policy { metadata: map[string]any{}, source: src, info: info, - semantic: firstMatch, + semantic: unspecified, imports: []*Import{}, } } @@ -49,13 +51,29 @@ type Policy struct { description ValueString imports []*Import rule *Rule - semantic semanticType + semantic SemanticType info *ast.SourceInfo source *Source metadata map[string]any } +// Semantic returns the evaluation semantic for the policy. +func (p *Policy) Semantic() SemanticType { + if p.semantic == unspecified { + return firstMatch + } + return p.semantic +} + +// SetSemantic configures the evaluation semantic for the policy. +func (p *Policy) SetSemantic(s SemanticType) { + if p.semantic != unspecified && p.semantic != s { + return + } + p.semantic = s +} + // Source returns the policy file contents as a CEL source object. func (p *Policy) Source() *Source { return p.source @@ -179,6 +197,7 @@ func NewRule(exprID int64) *Rule { exprID: exprID, variables: []*Variable{}, matches: []*Match{}, + semantic: unspecified, } } @@ -189,6 +208,28 @@ type Rule struct { description *ValueString variables []*Variable matches []*Match + semantic SemanticType +} + +// Semantic returns the evaluation semantic for the rule. +func (r *Rule) Semantic() SemanticType { + if r.semantic == unspecified { + return firstMatch + } + return r.semantic +} + +// SetSemantic configures the evaluation semantic for the rule. +func (r *Rule) SetSemantic(s SemanticType) { + if r.semantic != unspecified && r.semantic != s { + return + } + r.semantic = s +} + +// SourceID returns the source identifier associated with the rule. +func (r *Rule) SourceID() int64 { + return r.exprID } // ID returns the id value of the rule if it is set. @@ -249,6 +290,7 @@ func (r *Rule) getExplanationOutputRule() *Rule { er := Rule{ id: r.id, description: r.description, + semantic: r.semantic, } er.AddVariables(r.Variables()) for _, match := range r.matches { @@ -769,8 +811,18 @@ func (p *parserImpl) ParseRule(ctx ParserContext, policy *Policy, node *yaml.Nod r.SetDescription(ctx.NewString(val)) case "variables": p.parseVariables(ctx, policy, r, val) - case "match": - p.parseMatches(ctx, policy, r, val) + case "match", "aggregate": + sem := firstMatch + if fieldName == "aggregate" { + sem = aggregate + } + if r.semantic != unspecified && r.semantic != sem { + p.ReportErrorAtID(tagID, "Only one of 'match' or 'aggregate' may be set in a rule") + } else { + r.SetSemantic(sem) + policy.SetSemantic(sem) + p.parseMatches(ctx, policy, r, val) + } default: p.visitor.RuleTag(ctx, tagID, fieldName, val, policy, r) } @@ -802,7 +854,7 @@ func (p *parserImpl) ParseVariable(ctx ParserContext, policy *Policy, node *yaml return p.parseVariableObject(ctx, policy, v, node) } -func (p *parserImpl) parseVariableInline(ctx ParserContext, policy *Policy, v *Variable, node *yaml.Node) *Variable { +func (p *parserImpl) parseVariableInline(ctx ParserContext, _ *Policy, v *Variable, node *yaml.Node) *Variable { iterations := 0 p.RangeMap(node, func(key, val *yaml.Node) bool { keyVal := ctx.NewString(key) @@ -841,12 +893,16 @@ func (p *parserImpl) parseMatches(ctx ParserContext, policy *Policy, r *Rule, no return } for _, val := range node.Content { - r.AddMatch(p.ParseMatch(ctx, policy, val)) + r.AddMatch(p.parseMatchInternal(ctx, policy, r, val)) } } // ParseMatch will parse the current yaml node as though it is the entry point to a match. func (p *parserImpl) ParseMatch(ctx ParserContext, policy *Policy, node *yaml.Node) *Match { + return p.parseMatchInternal(ctx, policy, nil, node) +} + +func (p *parserImpl) parseMatchInternal(ctx ParserContext, policy *Policy, r *Rule, node *yaml.Node) *Match { m, id := ctx.NewMatch(node) if p.assertYAMLType(id, node, yamlMap) == nil || !p.checkMapValid(ctx, id, node) { return m @@ -868,7 +924,7 @@ func (p *parserImpl) ParseMatch(ctx ParserContext, policy *Policy, node *yaml.No p.ReportErrorAtID(keyID, "explanation can only be set on output match cases, not nested rules") } m.SetExplanation(ctx.NewString(val)) - case "rule": + case "rule", "match", "aggregate": if m.HasOutput() { p.ReportErrorAtID(keyID, "only the rule or the output may be set") } diff --git a/policy/parser_test.go b/policy/parser_test.go index 804df04f5..ddfa926f9 100644 --- a/policy/parser_test.go +++ b/policy/parser_test.go @@ -147,6 +147,19 @@ rule: }, { txt: ` +rule: + match: + - condition: "true" + output: "'foo'" + aggregate: + - condition: "true" + output: "'bar'"`, + err: `ERROR: :6:3: Only one of 'match' or 'aggregate' may be set in a rule + | aggregate: + | ..^`, + }, + { + txt: ` rule: match: - condition: "true" @@ -217,6 +230,30 @@ rule: | - name | ......^`, }, + { + txt: ` +name: test +rule: + match: + - output: 'true' + aggregate: + - output: 'true'`, + err: `ERROR: :6:3: Only one of 'match' or 'aggregate' may be set in a rule + | aggregate: + | ..^`, + }, + { + txt: ` +name: test +rule: + aggregate: + - output: 'true' + match: + - output: 'true'`, + err: `ERROR: :6:3: Only one of 'match' or 'aggregate' may be set in a rule + | match: + | ..^`, + }, } for _, tst := range tests { @@ -389,3 +426,36 @@ func (t *testTagHandler) PolicyTag(ctx ParserContext, id int64, tagName string, p.SetMetadata(tagName, node.Value) } } + +func TestPolicyAndRuleSemanticMethods(t *testing.T) { + p := NewPolicy(nil, nil) + if p.Semantic() != firstMatch { + t.Errorf("got %v, wanted firstMatch", p.Semantic()) + } + p.SetSemantic(aggregate) + if p.Semantic() != aggregate { + t.Errorf("got %v, wanted aggregate", p.Semantic()) + } + // Attempt to set conflicting semantic + p.SetSemantic(firstMatch) + if p.Semantic() != aggregate { + t.Errorf("got %v, wanted aggregate after conflicting SetSemantic", p.Semantic()) + } + + r := NewRule(123) + if r.SourceID() != 123 { + t.Errorf("got %v, wanted 123", r.SourceID()) + } + if r.Semantic() != firstMatch { + t.Errorf("got %v, wanted firstMatch", r.Semantic()) + } + r.SetSemantic(aggregate) + if r.Semantic() != aggregate { + t.Errorf("got %v, wanted aggregate", r.Semantic()) + } + // Attempt to set conflicting semantic + r.SetSemantic(firstMatch) + if r.Semantic() != aggregate { + t.Errorf("got %v, wanted aggregate after conflicting SetSemantic", r.Semantic()) + } +} diff --git a/policy/testdata/agent_tool_execution_governance/config.yaml b/policy/testdata/agent_tool_execution_governance/config.yaml new file mode 100644 index 000000000..d695615f2 --- /dev/null +++ b/policy/testdata/agent_tool_execution_governance/config.yaml @@ -0,0 +1,42 @@ +# 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: agent_tool_execution_governance +variables: + - name: "request.is_emergency" + type_name: "bool" + - name: "request.env" + type_name: "string" + - name: "tool.is_mutation" + type_name: "bool" + - name: "tool.call.args" + type_name: "map" + params: + - type_name: "string" + - type_name: "dyn" +functions: + - name: "hasCreditCard" + overloads: + - id: "hasCreditCard" + args: + - type_name: "dyn" + return: + type_name: "bool" + - name: "hasEmailOrPhone" + overloads: + - id: "hasEmailOrPhone" + args: + - type_name: "dyn" + return: + type_name: "bool" diff --git a/policy/testdata/agent_tool_execution_governance/policy.yaml b/policy/testdata/agent_tool_execution_governance/policy.yaml new file mode 100644 index 000000000..a8c0c01ef --- /dev/null +++ b/policy/testdata/agent_tool_execution_governance/policy.yaml @@ -0,0 +1,44 @@ +# 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: agent_tool_execution_governance +rule: + aggregate: + # Dimension 1: Approval Requirements (First-Match Escalation) + - rule: + match: + - condition: "request.is_emergency" + output: "'REQUIRE_VP_APPROVAL'" + - condition: "tool.is_mutation && request.env == 'prod'" + output: "'REQUIRE_TECH_LEAD_2FA'" + - condition: "tool.is_mutation" + output: "'REQUIRE_PEER_CONFIRMATION'" + + # Dimension 2: Data Redaction (First-Match Specificity) + - rule: + match: + - condition: "hasCreditCard(tool.call.args)" + output: "'REDACT_PCI'" + - condition: "hasEmailOrPhone(tool.call.args)" + output: "'REDACT_PII'" + + # Dimension 3: Rate Limiting (First-Match Threshold Ladder) + - rule: + match: + - condition: "tool.call.args.batch_size > 10000" + output: "'THROTTLE_TIER_3'" + - condition: "tool.call.args.batch_size > 1000" + output: "'THROTTLE_TIER_2'" + - condition: "tool.call.args.batch_size > 100" + output: "'THROTTLE_TIER_1'" diff --git a/policy/testdata/agent_tool_execution_governance/tests.yaml b/policy/testdata/agent_tool_execution_governance/tests.yaml new file mode 100644 index 000000000..ef4aa11a9 --- /dev/null +++ b/policy/testdata/agent_tool_execution_governance/tests.yaml @@ -0,0 +1,72 @@ +# 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. + +description: "Tests governance policy evaluation with multi-dimensional aggregate rules" +section: + - name: "emergency_approval" + tests: + - name: "emergency_trumps_all_approval_rules" + input: + request.is_emergency: + value: true + request.env: + value: "prod" + tool.is_mutation: + value: true + tool.call.args: + expr: "{'batch_size': 50}" + output: + expr: "['REQUIRE_VP_APPROVAL']" + - name: "prod_mutation_with_pci_and_throttling" + tests: + - name: "prod_mutation_pci_tier2" + input: + request.is_emergency: + value: false + request.env: + value: "prod" + tool.is_mutation: + value: true + tool.call.args: + expr: "{'batch_size': dyn(1500), 'cc': dyn('411111111111')}" + output: + expr: "['REQUIRE_TECH_LEAD_2FA', 'REDACT_PCI', 'THROTTLE_TIER_2']" + - name: "dev_mutation_with_pii_and_tier1" + tests: + - name: "dev_mutation_pii_tier1" + input: + request.is_emergency: + value: false + request.env: + value: "dev" + tool.is_mutation: + value: true + tool.call.args: + expr: "{'batch_size': dyn(500), 'email': dyn('user@example.com')}" + output: + expr: "['REQUIRE_PEER_CONFIRMATION', 'REDACT_PII', 'THROTTLE_TIER_1']" + - name: "read_only_tool" + tests: + - name: "no_rules_matched" + input: + request.is_emergency: + value: false + request.env: + value: "prod" + tool.is_mutation: + value: false + tool.call.args: + expr: "{'batch_size': 10}" + output: + expr: "[]" diff --git a/policy/testdata/aggregate_errors/config.yaml b/policy/testdata/aggregate_errors/config.yaml new file mode 100644 index 000000000..b0c22629d --- /dev/null +++ b/policy/testdata/aggregate_errors/config.yaml @@ -0,0 +1,15 @@ +# 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 diff --git a/policy/testdata/aggregate_errors/policy.yaml b/policy/testdata/aggregate_errors/policy.yaml new file mode 100644 index 000000000..b0f73e174 --- /dev/null +++ b/policy/testdata/aggregate_errors/policy.yaml @@ -0,0 +1,24 @@ +# Copyright 2024 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: + aggregate: + - condition: "true" + rule: + match: + - condition: "true" + output: "optional.of('USER_PII')" + - condition: "true" + output: "403" diff --git a/policy/testdata/aggregate_list_errors/config.yaml b/policy/testdata/aggregate_list_errors/config.yaml new file mode 100644 index 000000000..20edb6308 --- /dev/null +++ b/policy/testdata/aggregate_list_errors/config.yaml @@ -0,0 +1,15 @@ +# 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 diff --git a/policy/testdata/aggregate_list_errors/policy.yaml b/policy/testdata/aggregate_list_errors/policy.yaml new file mode 100644 index 000000000..3836e59f1 --- /dev/null +++ b/policy/testdata/aggregate_list_errors/policy.yaml @@ -0,0 +1,24 @@ +# Copyright 2024 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: + aggregate: + - condition: "true" + rule: + match: + - condition: "true" + output: "['tag1', 'tag2']" + - condition: "true" + output: "403" diff --git a/policy/testdata/aggregate_nested_mixed_semantics/config.yaml b/policy/testdata/aggregate_nested_mixed_semantics/config.yaml new file mode 100644 index 000000000..2c61341c9 --- /dev/null +++ b/policy/testdata/aggregate_nested_mixed_semantics/config.yaml @@ -0,0 +1,15 @@ +# 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_nested_mixed_semantics diff --git a/policy/testdata/aggregate_nested_mixed_semantics/policy.yaml b/policy/testdata/aggregate_nested_mixed_semantics/policy.yaml new file mode 100644 index 000000000..1b0afdd7a --- /dev/null +++ b/policy/testdata/aggregate_nested_mixed_semantics/policy.yaml @@ -0,0 +1,25 @@ +# Copyright 2024 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_nested_mixed_semantics +rule: + aggregate: + - condition: "true" + rule: + match: + - condition: "true" + rule: + aggregate: + - condition: "true" + output: "'foo'" diff --git a/repl/go.mod b/repl/go.mod index 603668e29..51cc9d53a 100644 --- a/repl/go.mod +++ b/repl/go.mod @@ -3,10 +3,10 @@ module github.com/authzed/cel-go/repl go 1.23.0 require ( + github.com/authzed/cel-go v0.26.1 cel.dev/expr v0.25.1 github.com/antlr4-go/antlr/v4 v4.13.1 github.com/chzyer/readline v1.5.1 - github.com/authzed/cel-go v0.26.1 github.com/google/go-cmp v0.7.0 go.yaml.in/yaml/v3 v3.0.4 google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 @@ -21,5 +21,3 @@ require ( ) replace github.com/authzed/cel-go => ../. - -replace cel.dev/expr => ../../cel-spec diff --git a/repl/go.sum b/repl/go.sum index 944635123..7cf762b5d 100644 --- a/repl/go.sum +++ b/repl/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= diff --git a/tools/go.mod b/tools/go.mod index 6f8ea594f..d794fdd2a 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -3,9 +3,9 @@ module github.com/authzed/cel-go/tools go 1.23.0 require ( - cel.dev/expr v0.25.1 - github.com/authzed/cel-go v0.22.0 + github.com/authzed/cel-go v0.26.1 github.com/authzed/cel-go/policy v0.0.0-20250311174852-f5ea07b389a1 + cel.dev/expr v0.25.1 github.com/google/go-cmp v0.7.0 go.yaml.in/yaml/v3 v3.0.4 google.golang.org/genproto/googleapis/api v0.0.0-20250311190419-81fb87f6b8bf @@ -14,11 +14,11 @@ require ( require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250311190419-81fb87f6b8bf // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/authzed/cel-go => ../. + +replace github.com/authzed/cel-go/policy => ../policy diff --git a/tools/go.sum b/tools/go.sum index a6b85e3cc..02f0ae46e 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -2,24 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/authzed/cel-go/policy v0.0.0-20250311174852-f5ea07b389a1 h1:jT/04RYwo++S9tvHggXWuAqvnc2Pi0BTHYsZYVOoMOs= -github.com/authzed/cel-go/policy v0.0.0-20250311174852-f5ea07b389a1/go.mod h1:dgvqy3CzFx17CBMkL0s1hd0r1+rEQOo85tDpr0g6Dp4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= @@ -34,6 +18,3 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=