Check projection budgets before container allocation

This commit is contained in:
2026-10-25 21:25:00 +00:00
parent ae3408a096
commit ea27f17f5c
4 changed files with 130 additions and 6 deletions

View File

@@ -159,6 +159,9 @@ Focused check: `go test ./internal/engine -run 'Mapping|Value|Projection|Clone|O
Finding: **A04**. Finding: **A04**.
Owners: `internal/engine/projection.go`, `value_operation.go`, `projection_test.go`. Owners: `internal/engine/projection.go`, `value_operation.go`, `projection_test.go`.
Status: **complete** (A04). Prior stage commits: `2736ddf`, `deb9aa5`,
`ae3408a`. Validation passed: `GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Projection|Inline|Resolution' -count=1` and `GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`.
1. After charging a container node and before its bulk reservation, compare its 1. After charging a container node and before its bulk reservation, compare its
minimum remaining cost with the current node allowance: at least one node per minimum remaining cost with the current node allowance: at least one node per
sequence element and one key plus one value node per map entry. Use comparisons sequence element and one key plus one value node per map entry. Use comparisons

View File

@@ -137,7 +137,9 @@ func (o *valueOperation) projectStruct(value reflect.Value, descriptor *typeDesc
if isNilValue(memberValue) { if isNilValue(memberValue) {
continue continue
} }
o.nodeCredits++
inline, issue := o.project(memberValue, member.descriptor, path, depth) inline, issue := o.project(memberValue, member.descriptor, path, depth)
o.nodeCredits--
if issue != nil { if issue != nil {
return nil, issue return nil, issue
} }
@@ -193,6 +195,9 @@ func (o *valueOperation) projectMap(value reflect.Value, descriptor *typeDescrip
if issue != nil { if issue != nil {
return nil, issue return nil, issue
} }
if issue := o.checkMinimumChildren(path, value.Len(), 2); issue != nil {
return nil, issue
}
result.Mapping = make([]document.Entry, 0, value.Len()) result.Mapping = make([]document.Entry, 0, value.Len())
keys := value.MapKeys() keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { slices.SortFunc(keys, func(left, right reflect.Value) int {
@@ -217,6 +222,9 @@ func (o *valueOperation) projectSequence(value reflect.Value, descriptor *typeDe
if issue != nil { if issue != nil {
return nil, issue return nil, issue
} }
if issue := o.checkMinimumChildren(path, value.Len(), 1); issue != nil {
return nil, issue
}
result.Sequence = make([]*document.Node, 0, value.Len()) result.Sequence = make([]*document.Node, 0, value.Len())
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
childPath := path.Index(index) childPath := path.Index(index)

View File

@@ -0,0 +1,101 @@
package engine
import (
"context"
"os"
"os/exec"
"reflect"
"testing"
"time"
)
type sliceBudgetConfig struct {
Values []string `yaml:"values"`
}
type arrayBudgetConfig struct {
Values [1]string `yaml:"values"`
}
type mapBudgetConfig struct {
Values map[string]string `yaml:"values"`
}
type inlineBudgetFields struct {
Name string `yaml:"name"`
}
type inlineStructBudgetConfig struct {
Fields inlineBudgetFields `yaml:",inline"`
}
type inlineMapBudgetConfig struct {
Fields map[string]string `yaml:",inline"`
}
type hugeZeroBudgetConfig struct {
Values []struct{} `yaml:"values"`
}
func TestProjectionChecksMinimumContainerNodes(t *testing.T) {
tests := []struct {
name string
typeOf reflect.Type
value any
exact int
}{
{name: "slice", typeOf: reflect.TypeFor[sliceBudgetConfig](), value: sliceBudgetConfig{Values: []string{"x"}}, exact: 4},
{name: "array", typeOf: reflect.TypeFor[arrayBudgetConfig](), value: arrayBudgetConfig{Values: [1]string{"x"}}, exact: 4},
{name: "map", typeOf: reflect.TypeFor[mapBudgetConfig](), value: mapBudgetConfig{Values: map[string]string{"x": "y"}}, exact: 5},
{name: "inline struct", typeOf: reflect.TypeFor[inlineStructBudgetConfig](), value: inlineStructBudgetConfig{Fields: inlineBudgetFields{Name: "x"}}, exact: 3},
{name: "inline map", typeOf: reflect.TypeFor[inlineMapBudgetConfig](), value: inlineMapBudgetConfig{Fields: map[string]string{"x": "y"}}, exact: 3},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
schema, issue := buildSchema(test.typeOf, testSecretBinding())
if issue != nil {
t.Fatal(issue.message)
}
for _, limits := range []struct {
name string
nodes int
fail bool
}{{name: "exact", nodes: test.exact}, {name: "insufficient", nodes: test.exact - 1, fail: true}} {
t.Run(limits.name, func(t *testing.T) {
_, projectionIssue := projectConfiguration(schema, reflect.ValueOf(test.value), projectionLimits{depth: maxDocumentDepth, nodes: limits.nodes})
if limits.fail {
if projectionIssue == nil || projectionIssue.code != CodeSchemaProjectionLimit {
t.Fatalf("projection issue = %#v, want limit failure", projectionIssue)
}
} else if projectionIssue != nil {
t.Fatalf("exact-budget projection issue = %#v", projectionIssue)
}
})
}
})
}
}
func TestProjectionRejectsMaximumZeroSizedSliceBeforeAllocation(t *testing.T) {
if os.Getenv("CONFIGKIT_HUGE_ZERO_SLICE_TEST") != "" {
schema := mustSchemaFor[hugeZeroBudgetConfig](t)
value := hugeZeroBudgetConfig{Values: make([]struct{}, int(^uint(0)>>1))}
_, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("projection issue = %#v, want a safe limit failure", issue)
}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestProjectionRejectsMaximumZeroSizedSliceBeforeAllocation$")
command.Env = append(os.Environ(), "CONFIGKIT_HUGE_ZERO_SLICE_TEST=1")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("maximum-length zero-sized slice projection did not terminate: %s", output)
}
if err != nil {
t.Fatalf("maximum-length zero-sized slice projection failed: %v, %s", err, output)
}
}

View File

@@ -27,11 +27,12 @@ type valueVisit struct {
// valueOperation owns one traversal's cycle and budget state and reuses the // valueOperation owns one traversal's cycle and budget state and reuses the
// current load's descriptor cache for dynamic values. // current load's descriptor cache for dynamic values.
type valueOperation struct { type valueOperation struct {
cloneMode secretCloneMode cloneMode secretCloneMode
schema *schemaDescriptor schema *schemaDescriptor
limits projectionLimits limits projectionLimits
active map[valueVisit]bool active map[valueVisit]bool
nodes int nodes int
nodeCredits int
} }
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) { func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
@@ -72,7 +73,18 @@ func descriptorContainsSecret(descriptor *typeDescriptor, visited map[*typeDescr
func (o *valueOperation) charge(path Path) *projectionIssue { func (o *valueOperation) charge(path Path) *projectionIssue {
o.nodes++ o.nodes++
if o.limits.nodes >= 0 && o.nodes > o.limits.nodes { if o.limits.nodes >= 0 && o.nodes-o.nodeCredits > o.limits.nodes {
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
}
return nil
}
func (o *valueOperation) checkMinimumChildren(path Path, count, nodesPerChild int) *projectionIssue {
if o.limits.nodes < 0 {
return nil
}
remaining := o.limits.nodes - (o.nodes - o.nodeCredits)
if remaining < 0 || count > remaining/nodesPerChild {
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"} return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
} }
return nil return nil