Harden configuration boundaries with fuzz coverage

This commit is contained in:
2026-09-06 22:35:01 +00:00
parent a8bfb8848d
commit 2836f825b6
6 changed files with 317 additions and 1 deletions

68
composition_fuzz_test.go Normal file
View File

@@ -0,0 +1,68 @@
package configkit
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func FuzzCompositionAndOverlay(f *testing.F) {
for _, seed := range [][2]string{
{"{}\n", "{}\n"},
{"left: {value: one}\n", "right: [two]\n"},
{"shared: {left: one}\n", "shared: {right: two}\n"},
{"value: one\n", "value: two\n"},
} {
f.Add([]byte(seed[0]), []byte(seed[1]))
}
limits := yamladapter.Limits{Depth: 12, Nodes: 256, ParserDepth: 24}
f.Fuzz(func(t *testing.T, leftInput, rightInput []byte) {
if len(leftInput) > 4<<10 || len(rightInput) > 4<<10 {
return
}
left, leftIssues := yamladapter.Parse(bytes.NewReader(leftInput), "left", limits)
right, rightIssues := yamladapter.Parse(bytes.NewReader(rightInput), "right", limits)
if len(leftIssues) != 0 || len(rightIssues) != 0 {
return
}
leftBefore, rightBefore := document.Clone(left), document.Clone(right)
composed, diagnostics := composeDocuments(left, right)
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition mutated an input document")
}
if len(diagnostics) == 0 {
reversed, reversedDiagnostics := composeDocuments(right, left)
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
}
if differences := compareDocumentTrees(composed.root, reversed.root); len(differences) != 0 {
t.Fatalf("composition order changed effective values: %#v", differences)
}
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition result aliases an input document")
}
}
base, baseDiagnostics := composeDocuments(left)
if len(baseDiagnostics) != 0 {
t.Fatalf("single accepted mapping did not compose: %#v", baseDiagnostics)
}
overlaid, overlayDiagnostics := overlayDocuments(base, right)
if len(overlayDiagnostics) != 0 {
t.Fatalf("accepted mappings did not overlay: %#v", overlayDiagnostics)
}
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay mutated an input document")
}
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay result aliases an input document")
}
})
}

View File

@@ -72,3 +72,14 @@ Once code exists, use focused checks while iterating and validation proportionat
to the change under the testing policy. Documentation-only work does not require to the change under the testing policy. Documentation-only work does not require
Go checks unless it changes behavior-bearing examples or commands they validate. Go checks unless it changes behavior-bearing examples or commands they validate.
Release validation is a separate, stricter gate governed by release policy. Release validation is a separate, stricter gate governed by release policy.
## Fuzz validation
Run the bounded fuzz targets independently from the repository root. These exact
commands keep each campaign short and constrain concurrent workers:
```sh
go test . -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
go test . -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
```

View File

@@ -1534,7 +1534,40 @@ Acceptance:
## Stage 21 — Harden boundaries and verify independent loads ## Stage 21 — Harden boundaries and verify independent loads
Status: pending. Status: complete.
Completion evidence (2026-09-06):
- Added bounded fuzz targets `FuzzParsePath`, `FuzzParseDocument`, and
`FuzzCompositionAndOverlay` for canonical pointer round trips, accepted YAML
parse/write stability, composition order, overlay behavior, input immutability,
and detached results. Each target has a small deterministic seed set and explicit
byte, translated-node, and depth bounds; the exact 30-second, two-worker commands
are recorded in the [developer guide](../development.md#fuzz-validation).
- Added concurrent public-load coverage that shares one read-only `Definition` while
each defaults factory returns fresh maps and slices. The test exercises custom
text scalars, independently mutable results, and the frozen effective snapshot.
A second load-level race test confirms copies of one `Reader` source compete for
one atomic claim, producing exactly one success and one `source.reader_used`
error.
- Reviewed the existing exact-edge source-byte, aggregate import/profile byte/node,
parser depth/node, schema traversal, typed projection, explanation-history, and
writer-error tests. Those boundaries already cover consequential reads,
traversal, projection, redacted rendering, and historical rendering, so no new
public limits or duplicate edge tests were added. YAML anchors, aliases, and merge
keys remain rejected; there is no provider extension gate or raw-secret rendering
route.
- Ran the three documented fuzz commands for 30 seconds each with `-parallel=2`.
Path and YAML passed directly. The first composition campaign revealed that its
initial fuzz-only bounds could leave an expensive case active at campaign
shutdown; tightening them to 4 KiB per document, 256 translated nodes, depth 12,
and parser depth 24 made the exact rerun pass without changing product behavior or
requiring a regression corpus.
- Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, `git diff --check`, and a fresh
`GOTOOLCHAIN=local GOPROXY=off go test ./...` with Go 1.26.7 on linux/amd64.
Existing skips are explicit platform/filesystem-capability probes; no default test
reads credentials, network services, or developer-specific configuration.
Read: testing policy, ownership/projection, import limits, redaction guarantees. Read: testing policy, ownership/projection, import limits, redaction guarantees.

116
independence_test.go Normal file
View File

@@ -0,0 +1,116 @@
package configkit
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"testing"
)
type independentText string
func (value independentText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *independentText) UnmarshalText(text []byte) error {
*value = independentText(text)
return nil
}
type independentConfig struct {
Labels map[string]string `yaml:"labels"`
Items []string `yaml:"items"`
Custom independentText `yaml:"custom"`
}
func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
definition := Definition[independentConfig]{
Defaults: func() independentConfig {
return independentConfig{
Labels: map[string]string{"default": "kept"},
Items: []string{"default"},
Custom: "default",
}
},
}
const count = 32
results := make([]*Result[independentConfig], count)
errorsByIndex := make([]error, count)
var group sync.WaitGroup
for index := range count {
group.Add(1)
go func(index int) {
defer group.Done()
input := fmt.Sprintf("custom: value-%d\n", index)
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
}(index)
}
group.Wait()
for index, err := range errorsByIndex {
if err != nil {
t.Fatalf("Load(%d) failed: %v", index, err)
}
}
results[0].Config.Labels["default"] = "mutated"
results[0].Config.Items[0] = "mutated"
results[0].Config.Custom = "mutated"
for index := 1; index < count; index++ {
if results[index].Config.Labels["default"] != "kept" || results[index].Config.Items[0] != "default" || results[index].Config.Custom != independentText(fmt.Sprintf("value-%d", index)) {
t.Fatalf("result %d shares mutable state: %#v", index, results[index].Config)
}
}
var effective bytes.Buffer
if err := results[0].WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "default: kept") || !strings.Contains(effective.String(), "value-0") {
t.Fatalf("frozen effective snapshot = %q", effective.String())
}
}
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
source := Reader("shared", strings.NewReader("value: one\n"))
copied := source
type config struct {
Value string `yaml:"value"`
}
start := make(chan struct{})
errorsFound := make(chan error, 2)
var group sync.WaitGroup
for _, candidate := range []Source{source, copied} {
group.Add(1)
go func(candidate Source) {
defer group.Done()
<-start
_, err := Load[config](candidate)
errorsFound <- err
}(candidate)
}
close(start)
group.Wait()
close(errorsFound)
successes, used := 0, 0
for err := range errorsFound {
if err == nil {
successes++
continue
}
var loadError *LoadError
if errors.As(err, &loadError) {
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 1 && diagnostics[0].Code == CodeSourceReaderUsed {
used++
}
}
}
if successes != 1 || used != 1 {
t.Fatalf("successes=%d reader-used=%d", successes, used)
}
}

View File

@@ -0,0 +1,61 @@
package yaml
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func FuzzParseDocument(f *testing.F) {
for _, seed := range []string{
"{}\n",
"service:\n enabled: true\n ports: [8080, 8081]\n",
"values: {empty: '', nothing: null, number: -1.5e2}\n",
"duplicate: one\nduplicate: two\n",
"alias: &value one\nother: *value\n",
} {
f.Add([]byte(seed))
}
limits := Limits{Depth: 64, Nodes: 4096, ParserDepth: 128}
f.Fuzz(func(t *testing.T, input []byte) {
if len(input) > 64<<10 {
return
}
parsed, issues := Parse(bytes.NewReader(input), "fuzz-input", limits)
if len(issues) != 0 {
return
}
var output bytes.Buffer
if err := Write(&output, parsed); err != nil {
t.Fatalf("Write() failed for accepted document: %v", err)
}
reparsed, issues := Parse(bytes.NewReader(output.Bytes()), "round-trip", limits)
if len(issues) != 0 {
t.Fatalf("rendered accepted document was rejected: %#v", issues)
}
clearLocations(parsed)
clearLocations(reparsed)
if !reflect.DeepEqual(parsed, reparsed) {
t.Fatalf("document changed across parse/write/parse: before=%#v after=%#v", parsed, reparsed)
}
})
}
func clearLocations(node *document.Node) {
if node == nil {
return
}
node.Location = document.Location{}
for index := range node.Mapping {
node.Mapping[index].KeyLocation = document.Location{}
node.Mapping[index].ValueLocation = document.Location{}
clearLocations(node.Mapping[index].Value)
}
for _, child := range node.Sequence {
clearLocations(child)
}
}

27
path_fuzz_test.go Normal file
View File

@@ -0,0 +1,27 @@
package configkit
import "testing"
func FuzzParsePath(f *testing.F) {
for _, seed := range []string{"", "/", "/service/port", "/a~1b/~0key", "/0", "relative", "/bad~2escape"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input string) {
if len(input) > 4096 {
return
}
path, err := ParsePath(input)
if err != nil {
return
}
canonical := path.String()
reparsed, err := ParsePath(canonical)
if err != nil {
t.Fatalf("canonical path %q did not parse: %v", canonical, err)
}
if reparsed.String() != canonical {
t.Fatalf("path round trip = %q, want %q", reparsed.String(), canonical)
}
})
}