blob: aa94e59388ac2380b2ca24c1f26d80018d43a499 [file]
// Copyright 2020 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 ext
import (
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
// TODO: move these tests to a conformance test.
var stringTests = []struct {
expr string
err string
parseOnly bool
}{
// CharAt test.
{expr: `'tacocat'.charAt(3) == 'o'`},
{expr: `'tacocat'.charAt(7) == ''`},
{expr: `'©αT'.charAt(0) == '©' && '©αT'.charAt(1) == 'α' && '©αT'.charAt(2) == 'T'`},
// Index of search string tests.
{expr: `'tacocat'.indexOf('') == 0`},
{expr: `'tacocat'.indexOf('ac') == 1`},
{expr: `'tacocat'.indexOf('none') == -1`},
{expr: `'tacocat'.indexOf('', 3) == 3`},
{expr: `'tacocat'.indexOf('a', 3) == 5`},
{expr: `'tacocat'.indexOf('at', 3) == 5`},
{expr: `'ta©o©αT'.indexOf('©') == 2`},
{expr: `'ta©o©αT'.indexOf('©', 3) == 4`},
{expr: `'ta©o©αT'.indexOf('©αT', 3) == 4`},
{expr: `'ta©o©αT'.indexOf('©α', 5) == -1`},
{expr: `'ijk'.indexOf('k') == 2`},
{expr: `'hello wello'.indexOf('hello wello') == 0`},
{expr: `'hello wello'.indexOf('ello', 6) == 7`},
{expr: `'hello wello'.indexOf('elbo room!!') == -1`},
{expr: `'hello wello'.indexOf('elbo room!!!') == -1`},
{expr: `''.lastIndexOf('@@') == -1`},
{expr: `'tacocat'.lastIndexOf('') == 7`},
{expr: `'tacocat'.lastIndexOf('at') == 5`},
{expr: `'tacocat'.lastIndexOf('none') == -1`},
{expr: `'tacocat'.lastIndexOf('', 3) == 3`},
{expr: `'tacocat'.lastIndexOf('a', 3) == 1`},
{expr: `'ta©o©αT'.lastIndexOf('©') == 4`},
{expr: `'ta©o©αT'.lastIndexOf('©', 3) == 2`},
{expr: `'ta©o©αT'.lastIndexOf('©α', 4) == 4`},
{expr: `'hello wello'.lastIndexOf('ello', 6) == 1`},
{expr: `'hello wello'.lastIndexOf('low') == -1`},
{expr: `'hello wello'.lastIndexOf('elbo room!!') == -1`},
{expr: `'hello wello'.lastIndexOf('elbo room!!!') == -1`},
{expr: `'hello wello'.lastIndexOf('hello wello') == 0`},
{expr: `'bananananana'.lastIndexOf('nana', 7) == 6`},
// Lower ASCII tests.
{expr: `'TacoCat'.lowerAscii() == 'tacocat'`},
{expr: `'TacoCÆt'.lowerAscii() == 'tacocÆt'`},
{expr: `'TacoCÆt Xii'.lowerAscii() == 'tacocÆt xii'`},
// Replace tests
{expr: `"12 days 12 hours".replace("{0}", "2") == "12 days 12 hours"`},
{expr: `"{0} days {0} hours".replace("{0}", "2") == "2 days 2 hours"`},
{expr: `"{0} days {0} hours".replace("{0}", "2", 1).replace("{0}", "23") == "2 days 23 hours"`},
{expr: `"1 ©αT taco".replace("αT", "o©α") == "1 ©o©α taco"`},
{expr: `"hello hello".replace("", "_") == "_h_e_l_l_o_ _h_e_l_l_o_"`},
{expr: `"hello hello".replace("h", "") == "ello ello"`},
// Split tests.
{expr: `"hello world".split(" ") == ["hello", "world"]`},
{expr: `"hello world events!".split(" ", 0) == []`},
{expr: `"hello world events!".split(" ", 1) == ["hello world events!"]`},
{expr: `"o©o©o©o".split("©", -1) == ["o", "o", "o", "o"]`},
// Substring tests.
{expr: `"tacocat".substring(4) == "cat"`},
{expr: `"tacocat".substring(7) == ""`},
{expr: `"tacocat".substring(0, 4) == "taco"`},
{expr: `"tacocat".substring(4, 4) == ""`},
{expr: `'ta©o©αT'.substring(2, 6) == "©o©α"`},
{expr: `'ta©o©αT'.substring(7, 7) == ""`},
// Trim tests using the unicode standard for whitespace.
{expr: `" \f\n\r\t\vtext ".trim() == "text"`},
{expr: `"\u0085\u00a0\u1680text".trim() == "text"`},
{expr: `"text\u2000\u2001\u2002\u2003\u2004\u2004\u2006\u2007\u2008\u2009".trim() == "text"`},
{expr: `"\u200atext\u2028\u2029\u202F\u205F\u3000".trim() == "text"`},
// Trim test with whitespace-like characters not included.
{expr: `"\u180etext\u200b\u200c\u200d\u2060\ufeff".trim()
== "\u180etext\u200b\u200c\u200d\u2060\ufeff"`},
// Upper ASCII tests.
{expr: `'tacoCat'.upperAscii() == 'TACOCAT'`},
{expr: `'tacoCαt'.upperAscii() == 'TACOCαT'`},
// Reverse tests.
{expr: `'gums'.reverse() == 'smug'`},
{expr: `'palindromes'.reverse() == 'semordnilap'`},
{expr: `'John Smith'.reverse() == 'htimS nhoJ'`},
{expr: `'u180etext'.reverse() == 'txete081u'`},
{expr: `'2600+U'.reverse() == 'U+0062'`},
{expr: `'\u180e\u200b\u200c\u200d\u2060\ufeff'.reverse() == '\ufeff\u2060\u200d\u200c\u200b\u180e'`},
// Join tests.
{expr: `['x', 'y'].join() == 'xy'`},
{expr: `['x', 'y'].join('-') == 'x-y'`},
{expr: `[].join() == ''`},
{expr: `[].join('-') == ''`},
// Escaping tests.
{expr: `strings.quote("first\nsecond") == "\"first\\nsecond\""`},
{expr: `strings.quote("bell\a") == "\"bell\\a\""`},
{expr: `strings.quote("\bbackspace") == "\"\\bbackspace\""`},
{expr: `strings.quote("\fform feed") == "\"\\fform feed\""`},
{expr: `strings.quote("carriage \r return") == "\"carriage \\r return\""`},
{expr: `strings.quote("horizontal tab\t") == "\"horizontal tab\\t\""`},
{expr: `strings.quote("vertical \v tab") == "\"vertical \\v tab\""`},
{expr: `strings.quote("double \\\\ slash") == "\"double \\\\\\\\ slash\""`},
{expr: `strings.quote("two escape sequences \a\n") == "\"two escape sequences \\a\\n\""`},
{expr: `strings.quote("verbatim") == "\"verbatim\""`},
{expr: `strings.quote("ends with \\") == "\"ends with \\\\\""`},
{expr: `strings.quote("\\ starts with") == "\"\\\\ starts with\""`},
{expr: `strings.quote("printable unicode😀") == "\"printable unicode😀\""`},
{expr: `strings.quote("mid string \" quote") == "\"mid string \\\" quote\""`},
{expr: `strings.quote('single-quote with "double quote"') == "\"single-quote with \\\"double quote\\\"\""`},
{expr: `strings.quote("size('ÿ')") == "\"size('ÿ')\""`},
{expr: `strings.quote("size('πέντε')") == "\"size('πέντε')\""`},
{expr: `strings.quote("завтра") == "\"завтра\""`},
{expr: `strings.quote("\U0001F431\U0001F600\U0001F61B") == "\"\U0001F431\U0001F600\U0001F61B\""`},
{expr: `strings.quote("ta©o©αT") == "\"ta©o©αT\""`},
{expr: `strings.quote("") == "\"\""`},
// Format tests with a non-literal as the format string
{
expr: `strings.quote('%s %s').format(['hello', 'world']) == "\"hello world\""`,
},
// Error test cases based on checked expression usage.
{
expr: `'tacocat'.charAt(30) == ''`,
err: "index out of range: 30",
},
{expr: `'tacocat'.indexOf('a', 30) == -1`},
{
expr: `'tacocat'.lastIndexOf('a', -1) == -1`,
err: "index out of range: -1",
},
{expr: `'tacocat'.lastIndexOf('a', 30) == -1`},
{
expr: `"tacocat".substring(40) == "cat"`,
err: "index out of range: 40",
},
{
expr: `"tacocat".substring(-1) == "cat"`,
err: "index out of range: -1",
},
{
expr: `"tacocat".substring(1, 50) == "cat"`,
err: "index out of range: 50",
},
{
expr: `"tacocat".substring(49, 50) == "cat"`,
err: "index out of range: 49",
},
{
expr: `"tacocat".substring(4, 3) == ""`,
err: "invalid substring range. start: 4, end: 3",
},
// Valid parse-only expressions which should generate runtime errors.
{
expr: `42.charAt(2) == ""`,
err: "no such overload",
parseOnly: true,
},
{
expr: `'hello'.charAt(true) == ""`,
err: "no such overload",
parseOnly: true,
},
{
expr: `24.indexOf('2') == 0`,
err: "no such overload",
parseOnly: true,
},
{
expr: `'hello'.indexOf(true) == 1`,
err: "no such overload",
parseOnly: true,
},
{
expr: `42.indexOf('4', 0) == 0`,
err: "no such overload",
parseOnly: true,
},
{
expr: `'42'.indexOf(4, 0) == 0`,
err: "no such overload",
parseOnly: true,
},
{
expr: `'42'.indexOf('4', '0') == 0`,
err: "no such overload",
parseOnly: true,
},
{
expr: `'42'.indexOf('4', 0, 1) == 0`,
err: "no such overload",
parseOnly: true,
},
{
expr: `42.split("2") == ["4"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `42.replace(2, 1) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".replace(2, 1) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".replace("2", 1) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `42.replace("2", "1", 1) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".replace(2, "1", 1) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".replace("2", 1, 1) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".replace("2", "1", "1") == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".replace("2", "1", 1, false) == "41"`,
err: "no such overload",
parseOnly: true,
},
{
expr: `42.split("") == ["4", "2"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".split(2) == ["4"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `42.split("2", "1") == ["4"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".split(2, 1) == ["4"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".split("2", "1") == ["4"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"42".split("2", 1, 1) == ["4"]`,
err: "no such overload",
parseOnly: true,
},
{
expr: `'hello'.substring(1, 2, 3) == ""`,
err: "no such overload",
parseOnly: true,
},
{
expr: `30.substring(true, 3) == ""`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"tacocat".substring(true, 3) == ""`,
err: "no such overload",
parseOnly: true,
},
{
expr: `"tacocat".substring(0, false) == ""`,
err: "no such overload",
parseOnly: true,
},
}
func TestStrings(t *testing.T) {
env, err := cel.NewEnv(Strings())
if err != nil {
t.Fatalf("cel.NewEnv(Strings()) failed: %v", err)
}
for i, tst := range stringTests {
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)
if !tc.parseOnly {
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 tc.err != "" {
if err == nil {
t.Fatalf("got value %v, wanted error %s for expr: %s",
out.Value(), tc.err, tc.expr)
}
if !strings.Contains(err.Error(), tc.err) {
t.Errorf("got %q, expected error to contain %q for expr: %s", err, tc.err, tc.expr)
}
} else if err != nil {
t.Fatal(err)
} else if out.Value() != true {
t.Errorf("got %v, wanted true for expr: %s", out.Value(), tc.expr)
}
}
})
}
}
func TestStringsVersions(t *testing.T) {
versionCases := []struct {
version uint32
supportedFunctions map[string]string
}{
{
version: 0,
supportedFunctions: map[string]string{
"chatAt": "''.charAt(0) == ''",
"indexOf": "'a'.indexOf('a') == 0",
"lastIndexOf": "'a'.lastIndexOf('a') == 0",
"join": "['a', 'b'].join() == 'ab'",
"joinSep": "['a', 'b'].join('-') == 'a-b'",
"lowerAscii": "'a'.lowerAscii() == 'a'",
"replace": "'hello hello'.replace('he', 'we') == 'wello wello'",
"split": "'hello hello hello'.split(' ') == ['hello', 'hello', 'hello']",
"substring": "'tacocat'.substring(4) == 'cat'",
"trim": "' \\ttrim\\n '.trim() == 'trim'",
"upperAscii": "'TacoCat'.upperAscii() == 'TACOCAT'",
},
},
{
version: 1,
supportedFunctions: map[string]string{
"format": "'a %d'.format([1]) == 'a 1'",
"quote": `strings.quote('\a \b "double quotes"') == '"\\a \\b \\"double quotes\\""'`,
},
},
{
version: 3,
supportedFunctions: map[string]string{
"reverse": "'taco'.reverse() == 'ocat'",
},
},
}
for _, lib := range versionCases {
env, err := cel.NewEnv(Strings(StringsVersion(lib.version)))
if err != nil {
t.Fatalf("cel.NewEnv(Strings(StringsVersion(%d))) failed: %v", lib.version, err)
}
t.Run(fmt.Sprintf("version=%d", lib.version), func(t *testing.T) {
for _, tc := range versionCases {
for name, expr := range tc.supportedFunctions {
supported := lib.version >= tc.version
t.Run(fmt.Sprintf("%s-supported=%t", name, supported), func(t *testing.T) {
ast, iss := env.Compile(expr)
if supported {
if iss.Err() != nil {
t.Errorf("unexpected error: %v", iss.Err())
}
} else {
if iss.Err() == nil || !strings.Contains(iss.Err().Error(), "undeclared reference") {
t.Errorf("got error %v, wanted error %s for expr: %s, version: %d", iss.Err(), "undeclared reference", expr, tc.version)
}
return
}
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)
}
if out != types.True {
t.Errorf("prg.Eval() got %v, wanted true", out)
}
})
}
}
})
}
}
func TestStringsWithExtension(t *testing.T) {
env, err := cel.NewEnv(Strings())
if err != nil {
t.Fatalf("cel.NewEnv(Strings()) failed: %v", err)
}
_, err = env.Extend(Strings())
if err != nil {
t.Fatalf("env.Extend(Strings()) failed: %v", err)
}
}
func mustParseDuration(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
panic(err)
}
return d
}
func unquote(s string) (string, error) {
r := []rune(sanitize(s))
if r[0] != '"' || r[len(r)-1] != '"' {
return "", fmt.Errorf("expected given string to be enclosed in double quotes: %q", r)
}
var unquotedStrBuilder strings.Builder
noQuotes := r[1 : len(r)-1]
for i := 0; i < len(noQuotes); {
c := noQuotes[i]
hasNext := i+1 < len(noQuotes)
if c == '\\' {
if hasNext {
nextChar := noQuotes[i+1]
switch nextChar {
case 'a':
unquotedStrBuilder.WriteRune('\a')
case 'b':
unquotedStrBuilder.WriteRune('\b')
case 'f':
unquotedStrBuilder.WriteRune('\f')
case 'n':
unquotedStrBuilder.WriteRune('\n')
case 'r':
unquotedStrBuilder.WriteRune('\r')
case 't':
unquotedStrBuilder.WriteRune('\t')
case 'v':
unquotedStrBuilder.WriteRune('\v')
case '\\':
unquotedStrBuilder.WriteRune('\\')
case '"':
unquotedStrBuilder.WriteRune('"')
default:
unquotedStrBuilder.WriteRune(c)
unquotedStrBuilder.WriteRune(nextChar)
}
i += 2
continue
}
}
unquotedStrBuilder.WriteRune(c)
i++
}
return unquotedStrBuilder.String(), nil
}
func TestQuoteUnquote(t *testing.T) {
tests := []struct {
name string
testStr string
expectedErr string
expectedOutput string
expectedRuntimeCost uint64
expectedEstimatedCost checker.CostEstimate
disableQuote bool
disableCELEval bool
}{
{
name: "remove quotes only",
testStr: "this is a test",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "mid-string newline",
testStr: "first\nsecond",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "bell",
testStr: "bell\a",
expectedEstimatedCost: checker.FixedCostEstimate(1),
expectedRuntimeCost: 1,
},
{
name: "backspace",
testStr: "\bbackspace",
expectedEstimatedCost: checker.FixedCostEstimate(1),
expectedRuntimeCost: 1,
},
{
name: "form feed",
testStr: "\fform feed",
expectedEstimatedCost: checker.FixedCostEstimate(1),
expectedRuntimeCost: 1,
},
{
name: "carriage return",
testStr: "carriage \r return",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "horizontal tab",
testStr: "horizontal \ttab",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "vertical tab",
testStr: "vertical \v tab",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "double slash",
testStr: "double \\\\ slash",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "two escape sequences",
testStr: "two escape sequences \a\n",
expectedEstimatedCost: checker.FixedCostEstimate(3),
expectedRuntimeCost: 3,
},
{
name: "ends with slash",
testStr: "ends with \\",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "starts with slash",
testStr: "\\ starts with",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "printable unicode",
testStr: "printable unicode😀",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "mid-string quote",
testStr: "mid-string \" quote",
expectedEstimatedCost: checker.FixedCostEstimate(2),
expectedRuntimeCost: 2,
},
{
name: "single-quote with double quote",
testStr: `single-quote with "double quote"`,
expectedEstimatedCost: checker.FixedCostEstimate(4),
expectedRuntimeCost: 4,
},
{
name: "CEL-only escape sequences",
testStr: "\\? and \\`",
expectedEstimatedCost: checker.FixedCostEstimate(1),
expectedRuntimeCost: 1,
},
{
name: "test cost",
testStr: "this is a very very very long string used to ensure that cost tracking works",
expectedEstimatedCost: checker.FixedCostEstimate(8),
expectedRuntimeCost: 8,
},
{
name: "missing opening quote",
testStr: `only one quote"`,
expectedErr: "expected given string to be enclosed in double quotes",
disableQuote: true,
},
{
name: "missing closing quote",
testStr: `"only one quote`,
expectedErr: "expected given string to be enclosed in double quotes",
disableQuote: true,
},
{
name: "invalid utf8",
testStr: "filler \x9f",
expectedOutput: "filler " + string(utf8.RuneError),
// disable CEL eval in order to simulate a string variable with invalid UTF-8
disableCELEval: true,
},
{
name: "trailing single slash",
testStr: "\"trailing slash \\\"",
expectedOutput: "trailing slash \\",
disableQuote: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s string
if tt.disableQuote {
s = tt.testStr
} else {
if tt.disableCELEval {
s, _ = quote(tt.testStr)
} else {
s = evalWithCEL(tt.testStr, tt.expectedRuntimeCost, tt.expectedEstimatedCost, t)
}
}
output, err := unquote(s)
if err != nil {
if tt.expectedErr != "" {
if !strings.Contains(err.Error(), tt.expectedErr) {
t.Fatalf("expected error message %q to contain %q", err, tt.expectedErr)
}
} else {
t.Fatalf("unexpected error: %s", err)
}
} else {
if tt.expectedErr != "" {
t.Fatalf("expected error message with substring %q but no error was seen", tt.expectedErr)
}
if tt.expectedOutput != "" {
if output != tt.expectedOutput {
t.Fatalf("expected output: %q, got: %q", tt.expectedOutput, output)
}
} else if output != tt.testStr {
t.Fatalf("input-output mismatch: original: %q, quote/unquote: %q", tt.testStr, output)
}
}
})
}
}
type noopCostEstimator struct{}
func (e *noopCostEstimator) CallCost(function, overloadID string, args []ref.Val, result ref.Val) *uint64 {
return nil
}
func (e *noopCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate {
return nil
}
func (e *noopCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate {
return nil
}
func evalWithCEL(input string, expectedRuntimeCost uint64, expectedEstimatedCost checker.CostEstimate, t *testing.T) string {
env, err := cel.NewEnv(Strings())
if err != nil {
t.Fatalf("cel.NewEnv() failed: %v", err)
}
expr := fmt.Sprintf(`strings.quote(%q)`, input)
parsedAst, issues := env.Parse(expr)
if issues.Err() != nil {
t.Fatalf("env.Parse() failed: %v", issues.Err())
}
checkedAst, issues := env.Check(parsedAst)
if issues.Err() != nil {
t.Fatalf("env.Check() failed: %v", issues.Err())
}
costTracker := &noopCostEstimator{}
actualEstimatedCost, err := env.EstimateCost(checkedAst, costTracker)
if err != nil {
t.Fatal(err)
}
if expectedEstimatedCost.Min != 0 && expectedEstimatedCost.Max != 0 {
if actualEstimatedCost.Min != expectedEstimatedCost.Min && actualEstimatedCost.Max != expectedEstimatedCost.Max {
t.Fatalf("expected estimated cost range to be %v, was %v", expectedEstimatedCost, actualEstimatedCost)
}
}
program, err := env.Program(checkedAst, cel.CostTracking(costTracker))
if err != nil {
t.Fatal(err)
}
out, evalDetails, err := program.Eval(cel.NoVars())
if err != nil {
t.Fatal(err)
}
if evalDetails == nil {
t.Fatal("evalDetails could not be calculated")
} else if evalDetails.ActualCost() == nil {
t.Fatal("could not calculate runtime cost")
}
if expectedRuntimeCost != 0 {
if *evalDetails.ActualCost() != expectedRuntimeCost {
t.Fatalf("expected runtime cost of %d, got %d", expectedRuntimeCost, *evalDetails.ActualCost())
}
if expectedEstimatedCost.Min != 0 && expectedEstimatedCost.Max != 0 {
if *evalDetails.ActualCost() < expectedEstimatedCost.Min || *evalDetails.ActualCost() > expectedEstimatedCost.Max {
t.Fatalf("runtime cost %d outside of expected estimated cost range %v", *evalDetails.ActualCost(), expectedEstimatedCost)
}
}
}
if out.Type() != types.StringType {
t.Fatalf("expected expr output to be a string, got %s", out.Type().TypeName())
}
return out.Value().(string)
}
func TestFunctionsForVersions(t *testing.T) {
tests := []struct {
version uint32
introducedFunctions []string
}{
{
version: 0,
introducedFunctions: []string{"lastIndexOf", "lowerAscii", "split", "trim", "join", "charAt", "indexOf", "replace", "substring", "upperAscii"},
},
{
version: 1,
introducedFunctions: []string{"strings.quote", "format"},
},
{
version: 2,
introducedFunctions: []string{}, // join changed, no functions added
},
{
version: 3,
introducedFunctions: []string{"reverse"},
},
}
var functions []string
for _, tt := range tests {
functions = append(functions, tt.introducedFunctions...)
t.Run(fmt.Sprintf("version %d", tt.version), func(t *testing.T) {
e, err := cel.NewCustomEnv(Strings(StringsVersion(tt.version)))
if err != nil {
t.Fatalf("NewEnv() failed: %v", err)
}
if len(functions) != len(e.Functions()) {
var functionNames []string
for name := range e.Functions() {
functionNames = append(functionNames, name)
}
t.Fatalf("Expected functions: %#v, got %#v", functions, functionNames)
}
for _, expected := range functions {
if !e.HasFunction(expected) {
t.Errorf("Expected HasFunction() to return true for '%s'", expected)
}
if _, ok := e.Functions()[expected]; !ok {
t.Errorf("Expected Functions() to include '%s'", expected)
}
}
})
}
}
func FuzzQuote(f *testing.F) {
tests := []string{
"this is a test",
`only one quote"`,
`"only one quote`,
"first\nsecond",
"bell\a",
"\bbackspace",
"\fform feed",
"carriage \r return",
"horizontal \ttab",
"vertical \v tab",
"double \\\\ slash",
"two escape sequences \a\n",
"ends with \\",
"\\ starts with",
"printable unicode😀",
"mid-string \" quote",
"\\? and \\`",
"filler \x9f",
"size('ÿ')",
"size('πέντε')",
"завтра",
"\U0001F431\U0001F600\U0001F61B",
"ta©o©αT",
}
for _, tc := range tests {
f.Add(tc)
}
f.Fuzz(func(t *testing.T, s string) {
quoted, err := quote(s)
if err != nil {
if utf8.ValidString(s) {
t.Errorf("unexpected error: %s", err)
}
} else {
unquoted, err := unquote(quoted)
if err != nil {
t.Errorf("unexpected error: %s", err)
} else if s != sanitize(s) {
if unquoted != sanitize(s) {
t.Errorf("input-output mismatch on test case containing invalid UTF-8: sanitized original: %q, quoted: %q, quote/unquote: %q", sanitize(s), quoted, unquoted)
}
} else if unquoted != s {
t.Errorf("input-output mismatch: original: %q, quoted: %q, quote/unquote: %q", s, quoted, unquoted)
}
}
})
}
func TestStringCostTracking(t *testing.T) {
tests := []struct {
name string
expr string
estimatedCost checker.CostEstimate
actualCost uint64
}{
{
name: "charAt",
expr: `"hello world".charAt(0)`,
estimatedCost: checker.FixedCostEstimate(4),
actualCost: 4,
},
{
name: "indexOf",
expr: `"hello world".indexOf("world")`,
estimatedCost: checker.FixedCostEstimate(7),
actualCost: 7,
},
{
name: "lastIndexOf",
expr: `"hello world".lastIndexOf("o")`,
estimatedCost: checker.FixedCostEstimate(3),
actualCost: 3,
},
{
name: "lowerAscii",
expr: `"HELLO".lowerAscii()`,
estimatedCost: checker.FixedCostEstimate(7),
actualCost: 7,
},
{
name: "upperAscii",
expr: `"hello".upperAscii()`,
estimatedCost: checker.FixedCostEstimate(7),
actualCost: 7,
},
{
name: "replace",
expr: `"hello world".replace("world", "CEL")`,
estimatedCost: checker.CostEstimate{Min: 11, Max: 55},
actualCost: 16,
},
{
name: "replace_exponential_growth",
expr: `"A".replace("", "AAAAAAAAAA").replace("", "AAAAAAAAAA")`,
estimatedCost: checker.CostEstimate{Min: 6, Max: 281},
actualCost: 268,
},
{
name: "split",
expr: `"a,b,c,d,e".split(",")`,
estimatedCost: checker.CostEstimate{Min: 12, Max: 21},
actualCost: 17,
},
{
name: "substring",
expr: `"hello world".substring(0, 5)`,
estimatedCost: checker.FixedCostEstimate(8),
actualCost: 8,
},
{
name: "join",
expr: `["a", "b", "c", "d", "e"].join("-")`,
estimatedCost: checker.CostEstimate{Min: 12, Max: 23},
actualCost: 21,
},
{
name: "trim",
expr: `" hello ".trim()`,
estimatedCost: checker.CostEstimate{Min: 2, Max: 11},
actualCost: 7,
},
{
name: "reverse",
expr: `"hello".reverse()`,
estimatedCost: checker.FixedCostEstimate(7),
actualCost: 7,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env, err := cel.NewEnv(Strings(StringsVersion(5)))
if err != nil {
t.Fatalf("cel.NewEnv() failed: %v", err)
}
ast, iss := env.Compile(tc.expr)
if iss.Err() != nil {
t.Fatalf("env.Compile(%q) failed: %v", tc.expr, iss.Err())
}
testCheckCost(t, env, ast, nil, tc.estimatedCost)
prg, err := env.Program(ast, cel.CostTracking(nil))
if err != nil {
t.Fatalf("env.Program() failed: %v", err)
}
_, det, err := prg.Eval(cel.NoVars())
if err != nil {
t.Fatalf("prg.Eval() failed: %v", err)
}
cost := det.ActualCost()
if cost == nil {
t.Fatal("cost tracking returned nil")
}
if *cost != tc.actualCost {
t.Errorf("cost for %q = %d, want at least %d", tc.expr, *cost, tc.actualCost)
}
})
}
}
func TestStringCostLimitEnforced(t *testing.T) {
env, err := cel.NewEnv(Strings())
if err != nil {
t.Fatalf("cel.NewEnv() failed: %v", err)
}
// Chained replaces that produce exponential output.
// Without cost tracking, this would be charged ~6. With tracking, the cost
// scales with output size and should exceed any reasonable limit.
expr := `"A".replace("", "AAAAAAAAAA").replace("", "AAAAAAAAAA").replace("", "AAAAAAAAAA").replace("", "AAAAAAAAAA").replace("", "AAAAAAAAAA").replace("", "AAAAAAAAAA")`
ast, iss := env.Compile(expr)
if iss.Err() != nil {
t.Fatalf("env.Compile() failed: %v", iss.Err())
}
prg, err := env.Program(ast, cel.CostLimit(1000), cel.CostTracking(nil))
if err != nil {
t.Fatalf("env.Program() failed: %v", err)
}
_, _, err = prg.Eval(cel.NoVars())
if err == nil {
t.Error("expected cost limit exceeded error for exponential string growth, got nil")
}
}