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
[](https://goreportcard.com/report/github.com/authzed/cel-go)
-[][6]
+[][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=