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
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
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