diff --git a/composition_fuzz_test.go b/composition_fuzz_test.go new file mode 100644 index 0000000..bbeb0a4 --- /dev/null +++ b/composition_fuzz_test.go @@ -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") + } + }) +} diff --git a/docs/development.md b/docs/development.md index a6062af..34becf4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 +``` diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7b9c5ab..aab4008 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/independence_test.go b/independence_test.go new file mode 100644 index 0000000..8d06f87 --- /dev/null +++ b/independence_test.go @@ -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) + } +} diff --git a/internal/yaml/fuzz_test.go b/internal/yaml/fuzz_test.go new file mode 100644 index 0000000..cc75e5b --- /dev/null +++ b/internal/yaml/fuzz_test.go @@ -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) + } +} diff --git a/path_fuzz_test.go b/path_fuzz_test.go new file mode 100644 index 0000000..b92a7d0 --- /dev/null +++ b/path_fuzz_test.go @@ -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) + } + }) +}