Harden configuration boundaries with fuzz coverage
This commit is contained in:
68
composition_fuzz_test.go
Normal file
68
composition_fuzz_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
Go checks unless it changes behavior-bearing examples or commands they validate.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1534,7 +1534,40 @@ Acceptance:
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
116
independence_test.go
Normal file
116
independence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
61
internal/yaml/fuzz_test.go
Normal file
61
internal/yaml/fuzz_test.go
Normal 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
27
path_fuzz_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user