From ea27f17f5ca93fa848002f4e9c1ab987968515eb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 25 Oct 2026 21:25:00 +0000 Subject: [PATCH] Check projection budgets before container allocation --- docs/roadmap/implementation.md | 3 + internal/engine/projection.go | 8 ++ internal/engine/projection_budget_test.go | 101 ++++++++++++++++++++++ internal/engine/value_operation.go | 24 +++-- 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 internal/engine/projection_budget_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c5df6fb..789ff08 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -159,6 +159,9 @@ Focused check: `go test ./internal/engine -run 'Mapping|Value|Projection|Clone|O Finding: **A04**. 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 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 diff --git a/internal/engine/projection.go b/internal/engine/projection.go index 730e6a3..ac3768e 100644 --- a/internal/engine/projection.go +++ b/internal/engine/projection.go @@ -137,7 +137,9 @@ func (o *valueOperation) projectStruct(value reflect.Value, descriptor *typeDesc if isNilValue(memberValue) { continue } + o.nodeCredits++ inline, issue := o.project(memberValue, member.descriptor, path, depth) + o.nodeCredits-- if issue != nil { return nil, issue } @@ -193,6 +195,9 @@ func (o *valueOperation) projectMap(value reflect.Value, descriptor *typeDescrip if issue != nil { return nil, issue } + if issue := o.checkMinimumChildren(path, value.Len(), 2); issue != nil { + return nil, issue + } result.Mapping = make([]document.Entry, 0, value.Len()) keys := value.MapKeys() 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 { return nil, issue } + if issue := o.checkMinimumChildren(path, value.Len(), 1); issue != nil { + return nil, issue + } result.Sequence = make([]*document.Node, 0, value.Len()) for index := 0; index < value.Len(); index++ { childPath := path.Index(index) diff --git a/internal/engine/projection_budget_test.go b/internal/engine/projection_budget_test.go new file mode 100644 index 0000000..0e38884 --- /dev/null +++ b/internal/engine/projection_budget_test.go @@ -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) + } +} diff --git a/internal/engine/value_operation.go b/internal/engine/value_operation.go index 77cfcf1..db6bf79 100644 --- a/internal/engine/value_operation.go +++ b/internal/engine/value_operation.go @@ -27,11 +27,12 @@ type valueVisit struct { // valueOperation owns one traversal's cycle and budget state and reuses the // current load's descriptor cache for dynamic values. type valueOperation struct { - cloneMode secretCloneMode - schema *schemaDescriptor - limits projectionLimits - active map[valueVisit]bool - nodes int + cloneMode secretCloneMode + schema *schemaDescriptor + limits projectionLimits + active map[valueVisit]bool + nodes int + nodeCredits int } 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 { 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 nil