Skip to content

Commit 6394fb8

Browse files
jpbetzk8s-publishing-bot
authored andcommitted
Fix CEL cost handling of zero length replacement strings
Kubernetes-commit: f3f88b8e7b51f15c91b4f004ff4ae04ed6b25ac0
1 parent d8f0b41 commit 6394fb8

File tree

2 files changed

+152
-5
lines changed

2 files changed

+152
-5
lines changed

pkg/cel/library/cost.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,47 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch
126126
sz := l.sizeEstimate(*target)
127127
toReplaceSz := l.sizeEstimate(args[0])
128128
replaceWithSz := l.sizeEstimate(args[1])
129-
// smallest possible result: smallest input size composed of the largest possible substrings being replaced by smallest possible replacement
130-
minSz := uint64(math.Ceil(float64(sz.Min)/float64(toReplaceSz.Max))) * replaceWithSz.Min
131-
// largest possible result: largest input size composed of the smallest possible substrings being replaced by largest possible replacement
132-
maxSz := uint64(math.Ceil(float64(sz.Max)/float64(toReplaceSz.Min))) * replaceWithSz.Max
129+
130+
var replaceCount, retainedSz checker.SizeEstimate
131+
// find the longest replacement:
132+
if toReplaceSz.Min == 0 {
133+
// if the string being replaced is empty, replace surrounds all characters in the input string with the replacement.
134+
if replaceCount.Max < math.MaxUint64 {
135+
replaceCount.Max = sz.Max + 1
136+
}
137+
// Include the length of the longest possible original string length.
138+
retainedSz.Max = sz.Max
139+
} else if replaceWithSz.Max <= toReplaceSz.Min {
140+
// If the replacement does not make the result longer, use the original string length.
141+
replaceCount.Max = 0
142+
retainedSz.Max = sz.Max
143+
} else {
144+
// Replace the smallest possible substrings with the largest possible replacement
145+
// as many times as possible.
146+
replaceCount.Max = uint64(math.Ceil(float64(sz.Max) / float64(toReplaceSz.Min)))
147+
}
148+
149+
// find the shortest replacement:
150+
if toReplaceSz.Max == 0 {
151+
// if the string being replaced is empty, replace surrounds all characters in the input string with the replacement.
152+
if replaceCount.Min < math.MaxUint64 {
153+
replaceCount.Min = sz.Min + 1
154+
}
155+
// Include the length of the shortest possible original string length.
156+
retainedSz.Min = sz.Min
157+
} else if toReplaceSz.Max <= replaceWithSz.Min {
158+
// If the replacement does not make the result shorter, use the original string length.
159+
replaceCount.Min = 0
160+
retainedSz.Min = sz.Min
161+
} else {
162+
// Replace the largest possible substrings being with the smallest possible replacement
163+
// as many times as possible.
164+
replaceCount.Min = uint64(math.Ceil(float64(sz.Min) / float64(toReplaceSz.Max)))
165+
}
166+
size := replaceCount.Multiply(replaceWithSz).Add(retainedSz)
133167

134168
// cost is the traversal plus the construction of the result
135-
return &checker.CallEstimate{CostEstimate: sz.MultiplyByCostFactor(2 * common.StringTraversalCostFactor), ResultSize: &checker.SizeEstimate{Min: minSz, Max: maxSz}}
169+
return &checker.CallEstimate{CostEstimate: sz.MultiplyByCostFactor(2 * common.StringTraversalCostFactor), ResultSize: &size}
136170
}
137171
case "split":
138172
if target != nil {

pkg/cel/library/cost_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,18 @@ func TestStringLibrary(t *testing.T) {
263263
expectEsimatedCost: checker.CostEstimate{Min: 3, Max: 3},
264264
expectRuntimeCost: 3,
265265
},
266+
{
267+
name: "replace between all chars",
268+
expr: "'abc 123 def 123'.replace('', 'x')",
269+
expectEsimatedCost: checker.CostEstimate{Min: 3, Max: 3},
270+
expectRuntimeCost: 3,
271+
},
272+
{
273+
name: "replace with empty",
274+
expr: "'abc 123 def 123'.replace('123', '')",
275+
expectEsimatedCost: checker.CostEstimate{Min: 3, Max: 3},
276+
expectRuntimeCost: 3,
277+
},
266278
{
267279
name: "replace with limit",
268280
expr: "'abc 123 def 123'.replace('123', '456', 1)",
@@ -437,6 +449,107 @@ func testCost(t *testing.T, expr string, expectEsimatedCost checker.CostEstimate
437449
}
438450
}
439451

452+
func TestSize(t *testing.T) {
453+
exactSize := func(size int) checker.SizeEstimate {
454+
return checker.SizeEstimate{Min: uint64(size), Max: uint64(size)}
455+
}
456+
exactSizes := func(sizes ...int) []checker.SizeEstimate {
457+
results := make([]checker.SizeEstimate, len(sizes))
458+
for i, size := range sizes {
459+
results[i] = exactSize(size)
460+
}
461+
return results
462+
}
463+
cases := []struct {
464+
name string
465+
function string
466+
overload string
467+
targetSize checker.SizeEstimate
468+
argSizes []checker.SizeEstimate
469+
expectSize checker.SizeEstimate
470+
}{
471+
{
472+
name: "replace empty with char",
473+
function: "replace",
474+
targetSize: exactSize(3), // e.g. abc
475+
argSizes: exactSizes(0, 1), // e.g. replace "" with "_"
476+
expectSize: exactSize(7), // e.g. _a_b_c_
477+
},
478+
{
479+
name: "maybe replace char with empty",
480+
function: "replace",
481+
targetSize: exactSize(3),
482+
argSizes: exactSizes(1, 0),
483+
expectSize: checker.SizeEstimate{Min: 0, Max: 3},
484+
},
485+
{
486+
name: "maybe replace repeated",
487+
function: "replace",
488+
targetSize: exactSize(4),
489+
argSizes: exactSizes(2, 4),
490+
expectSize: checker.SizeEstimate{Min: 4, Max: 8},
491+
},
492+
{
493+
name: "maybe replace empty",
494+
function: "replace",
495+
targetSize: exactSize(4),
496+
argSizes: []checker.SizeEstimate{{Min: 0, Max: 1}, {Min: 0, Max: 2}},
497+
expectSize: checker.SizeEstimate{Min: 0, Max: 14}, // len(__a__a__a__a__) == 14
498+
},
499+
{
500+
name: "replace non-empty size range, maybe larger",
501+
function: "replace",
502+
targetSize: exactSize(4),
503+
argSizes: []checker.SizeEstimate{{Min: 1, Max: 1}, {Min: 1, Max: 2}},
504+
expectSize: checker.SizeEstimate{Min: 4, Max: 8},
505+
},
506+
{
507+
name: "replace non-empty size range, maybe smaller",
508+
function: "replace",
509+
targetSize: exactSize(4),
510+
argSizes: []checker.SizeEstimate{{Min: 1, Max: 2}, {Min: 1, Max: 1}},
511+
expectSize: checker.SizeEstimate{Min: 2, Max: 4},
512+
},
513+
}
514+
est := &CostEstimator{SizeEstimator: &testCostEstimator{}}
515+
for _, tc := range cases {
516+
t.Run(tc.name, func(t *testing.T) {
517+
var targetNode checker.AstNode = testSizeNode{size: tc.targetSize}
518+
argNodes := make([]checker.AstNode, len(tc.argSizes))
519+
for i, arg := range tc.argSizes {
520+
argNodes[i] = testSizeNode{size: arg}
521+
}
522+
result := est.EstimateCallCost(tc.function, tc.overload, &targetNode, argNodes)
523+
if result.ResultSize == nil {
524+
t.Fatalf("Expected ResultSize but got none")
525+
}
526+
if *result.ResultSize != tc.expectSize {
527+
t.Fatalf("Expected %+v but got %+v", tc.expectSize, *result.ResultSize)
528+
}
529+
})
530+
}
531+
}
532+
533+
type testSizeNode struct {
534+
size checker.SizeEstimate
535+
}
536+
537+
func (t testSizeNode) Path() []string {
538+
return nil // not needed
539+
}
540+
541+
func (t testSizeNode) Type() *expr.Type {
542+
return nil // not needed
543+
}
544+
545+
func (t testSizeNode) Expr() *expr.Expr {
546+
return nil // not needed
547+
}
548+
549+
func (t testSizeNode) ComputedSize() *checker.SizeEstimate {
550+
return &t.size
551+
}
552+
440553
type testCostEstimator struct {
441554
}
442555

0 commit comments

Comments
 (0)