From dd1bd2ffb8768faddf0042816bb57cbce46766d1 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:00:30 +1000 Subject: [PATCH 1/3] test: fuzz config and protocol parsers --- Makefile | 6 +- config/config_test.go | 12 +- config/fuzz_test.go | 112 ++++++++++++++++++ .../FuzzConfigParseCanonical/5095979aeaacb19a | 2 + docs/README.md | 1 + docs/fuzzing.md | 25 ++++ protocol/fuzz_test.go | 98 +++++++++++++++ protocol/jsonl.go | 6 +- protocol/jsonl_test.go | 27 ++++- .../FuzzDecodeLineBounded/6905685302560345 | 2 + .../FuzzDecodeLineBounded/faed0eb43a2d0f14 | 2 + .../rigor/generated/dependency-inventory.json | 1 + scripts/rigor/run-parser-fuzz.sh | 20 ++++ 13 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 config/fuzz_test.go create mode 100644 config/testdata/fuzz/FuzzConfigParseCanonical/5095979aeaacb19a create mode 100644 docs/fuzzing.md create mode 100644 protocol/fuzz_test.go create mode 100644 protocol/testdata/fuzz/FuzzDecodeLineBounded/6905685302560345 create mode 100644 protocol/testdata/fuzz/FuzzDecodeLineBounded/faed0eb43a2d0f14 create mode 100755 scripts/rigor/run-parser-fuzz.sh diff --git a/Makefile b/Makefile index 9470d31..abf1d99 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ GO_FILES := $(shell rg --files -g '*.go' .) RIGOR := $(GO) run ./scripts/rigor/cmd/rigor .PHONY: help tools fmt fmt-check lint vet test race coverage coverage-threshold \ - fuzz-smoke benchmark-smoke verify-performance govulncheck dependency-inventory license-inventory \ + fuzz-smoke fuzz-parser-long benchmark-smoke verify-performance govulncheck dependency-inventory license-inventory \ suppression-check \ api-refresh api-boundary traceability-refresh schema-freshness generated-refresh \ adapters conformance-check atlas-check workflow-validate hooks-install hooks-pre-commit-dry-run \ @@ -21,6 +21,7 @@ help: 'Available targets:' \ ' make fast # local fast gate (fmt, lint, vet, API/schema checks)' \ ' make verify # full local verification suite' \ + ' make fuzz-parser-long # run bounded config and agent-protocol fuzz targets' \ ' make ci # canonical local CI entrypoint' \ ' make generated-refresh # refresh tracked rigor inventories' @@ -63,6 +64,9 @@ coverage-threshold: coverage fuzz-smoke: ./scripts/rigor/run-fuzz-smoke.sh +fuzz-parser-long: + ./scripts/rigor/run-parser-fuzz.sh + benchmark-smoke: $(GO) test -run '^$$' -bench . -benchtime=1x ./... $(GO) run ./cmd/stave-performance -samples=11 diff --git a/config/config_test.go b/config/config_test.go index 987cc04..f5d7eab 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -132,7 +132,7 @@ func TestCanonicalRoundTripAndHashDeterminism(t *testing.T) { cfg.Theme.ID = "brand" cfg.Viewport = Viewport{Width: 100, Height: 40} data := CanonicalJSON(cfg) - roundTrip, err := Parse(data) + roundTrip, err := decodeCanonicalConfig(data) if err != nil { t.Fatal(err) } @@ -144,6 +144,16 @@ func TestCanonicalRoundTripAndHashDeterminism(t *testing.T) { } } +func TestCanonicalV1Bytes(t *testing.T) { + cfg := Defaults() + cfg.Theme.ID = "brand" + cfg.Viewport = Viewport{Width: 100, Height: 40} + const want = `{"schemaVersion":"stave.config/v1","app":{},"theme":{"id":"brand","mode":"auto","density":"comfortable"},"viewport":{"width":100,"height":40},"capabilities":{"color":"auto","unicode":"auto","motion":"auto","mouse":"auto","alternateScreen":"auto"},"keymap":{},"runtime":{"mode":"auto","inputQueue":256,"actionQueue":64,"restoreOnPanic":true},"protocol":{"enabled":true,"transport":"stdio-jsonl","maxMessageBytes":4194304},"security":{"confirmationTTL":"60s","maxTreeNodes":100000},"diagnostics":{"level":"warn","format":"text"}}` + if got := string(CanonicalJSON(cfg)); got != want { + t.Fatalf("canonical v1 bytes changed:\nwant %s\n got %s", want, got) + } +} + func TestLayerHelpersRejectUnknownKeys(t *testing.T) { if _, err := LayerFromEnv(map[string]string{"STAVE_UNKNOWN": "1"}); err == nil { t.Fatal("expected env key rejection") diff --git a/config/fuzz_test.go b/config/fuzz_test.go new file mode 100644 index 0000000..a846a28 --- /dev/null +++ b/config/fuzz_test.go @@ -0,0 +1,112 @@ +package config + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" +) + +const maxConfigFuzzBytes = 64 << 10 + +func FuzzConfigParseCanonical(f *testing.F) { + for _, seed := range configFuzzSeeds() { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > maxConfigFuzzBytes { + return + } + if depth, valid := jsonNesting(input); valid && depth > 64 { + return + } + config, err := Parse(input) + if err != nil { + if strings.Contains(err.Error(), "fuzz-secret") { + t.Fatalf("config parser echoed a secret: %v", err) + } + return + } + canonical := CanonicalJSON(config) + roundTrip, err := decodeCanonicalConfig(canonical) + if err != nil { + t.Fatalf("accepted config did not round trip: %v", err) + } + if !bytes.Equal(canonical, CanonicalJSON(roundTrip)) { + t.Fatalf("accepted config canonical encoding was unstable") + } + if HashString(config) != HashString(roundTrip) { + t.Fatalf("accepted config canonical hash was unstable") + } + }) +} + +// decodeCanonicalConfig decodes a resolved Config. Parse consumes sparse +// layers and applies Defaults, so it is intentionally not this decoder. +func decodeCanonicalConfig(data []byte) (Config, error) { + var config Config + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&config); err != nil { + return Config{}, err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return Config{}, errors.New("canonical config has trailing JSON") + } + if err := Validate(config); err != nil { + return Config{}, err + } + return config, nil +} + +func configFuzzSeeds() [][]byte { + valid := []byte(`{"schemaVersion":"stave.config/v1","theme":{"mode":"dark"},"protocol":{"maxMessageBytes":4194304}}`) + nested := []byte(`{"schemaVersion":"stave.config/v1","theme":{"mode":{"nested":true}}}`) + limit := append([]byte(`{"schemaVersion":"stave.config/v1"}`), bytes.Repeat([]byte(" "), maxConfigFuzzBytes-len(`{"schemaVersion":"stave.config/v1"}`))...) + return [][]byte{ + valid, + []byte(`{"schemaVersion":"stave.config/v1","theme":{"mode":"dark","unknown":true}}`), + []byte(`{"schemaVersion":"stave.config/v1","schemaVersion":"stave.config/v1"}`), + []byte(`{"schemaVersion":"stave.config/v1"} {}`), + []byte(`null`), + []byte(`{"schemaVersion":"stave.config/v1","app":{"id":"fuzz-secret"},"unknown":true}`), + []byte(`{"schemaVersion":"stave.config/v1","protocol":{"enabled":false}}`), + []byte(`{"schemaVersion":"stave.config/v1","runtime":{"restoreOnPanic":false}}`), + []byte(`{"schemaVersion":"stave.config/v1","app":{"id":"brace { in a string }"}}`), + []byte("\xff{"), + nested, + limit, + append(append([]byte(nil), limit...), 'x'), + } +} + +// jsonNesting counts JSON delimiters through tokens so braces in strings do +// not exclude valid seeds from the bounded fuzz domain. +func jsonNesting(input []byte) (maximum int, valid bool) { + decoder := json.NewDecoder(bytes.NewReader(input)) + depth := 0 + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + return maximum, true + } + if err != nil { + return 0, false + } + switch delimiter := token.(type) { + case json.Delim: + switch delimiter { + case '{', '[': + depth++ + if depth > maximum { + maximum = depth + } + case '}', ']': + depth-- + } + } + } +} diff --git a/config/testdata/fuzz/FuzzConfigParseCanonical/5095979aeaacb19a b/config/testdata/fuzz/FuzzConfigParseCanonical/5095979aeaacb19a new file mode 100644 index 0000000..b206499 --- /dev/null +++ b/config/testdata/fuzz/FuzzConfigParseCanonical/5095979aeaacb19a @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"sChemAVersion\":\"\",\"theme\":{\"mode\":\"\"}}") diff --git a/docs/README.md b/docs/README.md index db71b70..65766db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ same semantic interface for people and automation. - [Adopt Stave in an application](client-adoption.md) - [Use Stave UI primitives](primitives.md) - [Meet accessibility and agent-control expectations](accessibility-agent-parity.md) +- [Run bounded parser fuzzing](fuzzing.md) - [Understand compatibility guarantees](compatibility.md) - [Apply the security contract](security.md) - [Publish a release candidate](releasing.md) diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 0000000..e5686f3 --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,25 @@ +# Parser fuzzing + +`make fuzz-smoke` discovers every repository fuzz target and runs one bounded +iteration. It includes configuration parsing/canonicalization and JSON-RPC +request framing. The parser targets seed duplicate and unknown fields, trailing +and null input, malformed UTF-8 and JSON, nesting boundaries, and exact input +size boundaries. + +For a separately chosen, bounded parser run, use a local checkout: + +```sh +STAVE_PARSER_FUZZ_TIME=30s make fuzz-parser-long +``` + +The command first verifies that both required parser fuzz targets are present. +`config.Parse` consumes a sparse layer and applies defaults. Canonical output +represents a resolved `Config`, so the configuration target decodes it into a +zero `Config` with strict JSON checks and then validates it before comparing +canonical bytes and hashes. Accepted configuration and request values must +round-trip canonically; invalid input must be bounded and must not echo the +seeded secret value in configuration or protocol errors. The input-size and +nesting limits are fuzz-harness bounds, not claimed production parser limits; +the nesting guard uses JSON tokens so braces in string values remain covered. +A completed fuzz run exercises only the chosen time budget. It does not claim +that the library has no vulnerabilities. diff --git a/protocol/fuzz_test.go b/protocol/fuzz_test.go new file mode 100644 index 0000000..e8744d4 --- /dev/null +++ b/protocol/fuzz_test.go @@ -0,0 +1,98 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "io" + "reflect" + "strings" + "testing" +) + +const maxProtocolFuzzBytes = 4 << 10 + +func FuzzDecodeLineBounded(f *testing.F) { + for _, seed := range protocolFuzzSeeds() { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > maxProtocolFuzzBytes { + if _, err := DecodeLine(input, maxProtocolFuzzBytes); err == nil { + t.Fatal("oversized protocol input was accepted") + } + return + } + request, err := DecodeLine(input, maxProtocolFuzzBytes) + if err != nil { + if strings.Contains(err.Error(), "fuzz-secret") { + t.Fatalf("protocol parser echoed a secret: %v", err) + } + return + } + canonical, err := json.Marshal(request) + if err != nil { + t.Fatalf("accepted request did not marshal: %v", err) + } + roundTrip, err := DecodeLine(canonical, maxProtocolFuzzBytes) + if err != nil { + t.Fatalf("accepted request did not round trip: %v", err) + } + if !semanticJSONEqual(request.ID, roundTrip.ID) || request.JSONRPC != roundTrip.JSONRPC || request.Method != roundTrip.Method || !semanticJSONEqual(request.Params, roundTrip.Params) { + t.Fatalf("accepted request changed during round trip") + } + }) +} + +func protocolFuzzSeeds() [][]byte { + valid := []byte(`{"jsonrpc":"2.0","id":"0007","method":"stave.ping","params":{"value":true}}`) + limit := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maxProtocolFuzzBytes-len(valid))...) + return [][]byte{ + valid, + []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","unexpected":true}`), + []byte(`{"jsonrpc":"2.0","id":1,"fuzz-secret":1,"fuzz-secret":2,"method":"stave.ping"}`), + []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping"} {}`), + []byte(`{"jsonrpc":"2.0", "id": "0007", "method": "stave.ping", "params": { "value": true } }`), + []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","params":{"token":"fuzz-secret"},"unexpected":true}`), + []byte(`null`), + []byte("\xff{"), + protocolNestedSeed(64), + protocolNestedSeed(65), + limit, + append(append([]byte(nil), limit...), 'x'), + } +} + +func semanticJSONEqual(left, right []byte) bool { + leftValue, leftOK := decodeJSONValue(left) + rightValue, rightOK := decodeJSONValue(right) + return leftOK && rightOK && reflect.DeepEqual(leftValue, rightValue) +} + +func decodeJSONValue(value []byte) (any, bool) { + if len(value) == 0 { + return nil, true + } + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return nil, false + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, false + } + return decoded, true +} + +func protocolNestedSeed(depth int) []byte { + output := []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","params":`) + for range depth { + output = append(output, []byte(`{"nested":`)...) + } + output = append(output, []byte(`null`)...) + for range depth { + output = append(output, '}') + } + return append(output, '}') +} diff --git a/protocol/jsonl.go b/protocol/jsonl.go index 42f131c..cc8616c 100644 --- a/protocol/jsonl.go +++ b/protocol/jsonl.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "strings" "unicode/utf8" ) @@ -37,6 +38,9 @@ func DecodeLine(line []byte, max int) (Request, error) { dec.UseNumber() dec.DisallowUnknownFields() if err := dec.Decode(&r); err != nil { + if strings.HasPrefix(err.Error(), "json: unknown field ") { + return Request{}, errors.New("unknown JSON-RPC envelope field") + } return Request{}, err } if err := ensureEOF(dec); err != nil { @@ -85,7 +89,7 @@ func validateObject(b []byte, depth int) error { return errors.New("invalid object key") } if _, exists := seen[key]; exists { - return fmt.Errorf("duplicate key %q", key) + return errors.New("duplicate object key") } seen[key] = struct{}{} var raw json.RawMessage diff --git a/protocol/jsonl_test.go b/protocol/jsonl_test.go index b581984..4861465 100644 --- a/protocol/jsonl_test.go +++ b/protocol/jsonl_test.go @@ -1,6 +1,9 @@ package protocol -import "testing" +import ( + "strings" + "testing" +) func TestDecodeLineRejectsDuplicateAndTrailing(t *testing.T) { for _, in := range []string{`{"jsonrpc":"2.0","id":1,"method":"x","params":{"a":1,"a":2}}`, `{"jsonrpc":"2.0","id":1,"method":"x"} {"x":1}`, `[{}]`, "\xff"} { @@ -11,9 +14,16 @@ func TestDecodeLineRejectsDuplicateAndTrailing(t *testing.T) { } func TestDecodeLineRejectsUnknownEnvelopeFields(t *testing.T) { - if _, err := DecodeLine([]byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","unexpected":true}`), 1024); err == nil { + _, err := DecodeLine([]byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","fuzz-secret":true}`), 1024) + if err == nil { t.Fatal("unknown JSON-RPC envelope field was accepted") } + if strings.Contains(err.Error(), "fuzz-secret") { + t.Fatalf("unknown envelope field leaked through error: %v", err) + } + if err.Error() != "unknown JSON-RPC envelope field" { + t.Fatalf("unknown envelope error = %q", err) + } } func TestDecodeLinePreservesExactID(t *testing.T) { r, err := DecodeLine([]byte(`{"jsonrpc":"2.0","id":"0007","method":"stave.ping"}`), 1024) @@ -36,3 +46,16 @@ func TestDecodeLineLimitsDepth(t *testing.T) { t.Fatal("accepted excessive nesting") } } + +func TestDecodeLineRedactsNestedDuplicateKey(t *testing.T) { + _, err := DecodeLine([]byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","params":{"fuzz-secret":{"nested":1},"fuzz-secret":{"nested":2}}}`), 1024) + if err == nil { + t.Fatal("accepted nested duplicate key") + } + if strings.Contains(err.Error(), "fuzz-secret") { + t.Fatalf("duplicate key leaked through error: %v", err) + } + if err.Error() != "duplicate object key" { + t.Fatalf("duplicate key error = %q", err) + } +} diff --git a/protocol/testdata/fuzz/FuzzDecodeLineBounded/6905685302560345 b/protocol/testdata/fuzz/FuzzDecodeLineBounded/6905685302560345 new file mode 100644 index 0000000..e927736 --- /dev/null +++ b/protocol/testdata/fuzz/FuzzDecodeLineBounded/6905685302560345 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"jsonrpc\":\"2.0\", \"id\": \"&000\", \"method\": \"0000000000\", \"pArAms\": { \"00000\": true } }") diff --git a/protocol/testdata/fuzz/FuzzDecodeLineBounded/faed0eb43a2d0f14 b/protocol/testdata/fuzz/FuzzDecodeLineBounded/faed0eb43a2d0f14 new file mode 100644 index 0000000..09dd5ff --- /dev/null +++ b/protocol/testdata/fuzz/FuzzDecodeLineBounded/faed0eb43a2d0f14 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"fuzz-secret\":\"\"}") diff --git a/scripts/rigor/generated/dependency-inventory.json b/scripts/rigor/generated/dependency-inventory.json index 0f015c8..e96d646 100644 --- a/scripts/rigor/generated/dependency-inventory.json +++ b/scripts/rigor/generated/dependency-inventory.json @@ -426,6 +426,7 @@ "github.com/ben-ranford/stave/diag", "github.com/ben-ranford/stave/semantic", "io", + "strings", "unicode/utf8" ] }, diff --git a/scripts/rigor/run-parser-fuzz.sh b/scripts/rigor/run-parser-fuzz.sh new file mode 100755 index 0000000..a46f02f --- /dev/null +++ b/scripts/rigor/run-parser-fuzz.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +fuzz_time="${STAVE_PARSER_FUZZ_TIME:-30s}" + +targets=( + "./config:FuzzConfigParseCanonical" + "./protocol:FuzzDecodeLineBounded" +) + +for target in "${targets[@]}"; do + pkg="${target%%:*}" + name="${target##*:}" + discovered="$(go test "${pkg}" -list "^${name}$" 2>/dev/null)" + if ! rg -qx "${name}" <<<"${discovered}"; then + printf 'required parser fuzz target missing: %s in %s\n' "${name}" "${pkg}" >&2 + exit 1 + fi + go test "${pkg}" -run '^$' -fuzz "^${name}$" -fuzztime="${fuzz_time}" +done From c9a17f1dd7c902e3256d1a1cbf2bfb2bad882148 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:26:44 +1000 Subject: [PATCH 2/3] test(protocol): account for JSON expansion in fuzz roundtrips --- CHANGELOG.md | 5 +++++ docs/fuzzing.md | 9 ++++++--- protocol/fuzz_test.go | 12 +++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7989b..b8297d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Keep a Changelog and Stave uses Semantic Versioning. ## [Unreleased] +### Security + +- Redact unknown and duplicate JSON-RPC envelope field names from protocol + errors so attacker-controlled keys do not enter client diagnostics. + ## [1.0.0-rc.2] - 2026-09-10 ### Security diff --git a/docs/fuzzing.md b/docs/fuzzing.md index e5686f3..5fdf278 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -18,8 +18,11 @@ represents a resolved `Config`, so the configuration target decodes it into a zero `Config` with strict JSON checks and then validates it before comparing canonical bytes and hashes. Accepted configuration and request values must round-trip canonically; invalid input must be bounded and must not echo the -seeded secret value in configuration or protocol errors. The input-size and -nesting limits are fuzz-harness bounds, not claimed production parser limits; -the nesting guard uses JSON tokens so braces in string values remain covered. +seeded secret value in configuration or protocol errors. The configuration input-size and +nesting bounds belong to the fuzz harness; its nesting guard uses JSON tokens +so braces in strings remain covered. The protocol parser separately enforces +the caller-provided byte cap and its production depth-64 guard. Protocol +round-trip checks allow the emitted JSON length because escaping can expand +an accepted input. A completed fuzz run exercises only the chosen time budget. It does not claim that the library has no vulnerabilities. diff --git a/protocol/fuzz_test.go b/protocol/fuzz_test.go index e8744d4..f8450b9 100644 --- a/protocol/fuzz_test.go +++ b/protocol/fuzz_test.go @@ -33,7 +33,7 @@ func FuzzDecodeLineBounded(f *testing.F) { if err != nil { t.Fatalf("accepted request did not marshal: %v", err) } - roundTrip, err := DecodeLine(canonical, maxProtocolFuzzBytes) + roundTrip, err := DecodeLine(canonical, len(canonical)) if err != nil { t.Fatalf("accepted request did not round trip: %v", err) } @@ -48,6 +48,7 @@ func protocolFuzzSeeds() [][]byte { limit := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maxProtocolFuzzBytes-len(valid))...) return [][]byte{ valid, + []byte(`{"jsonrpc":"2.0","id":1,"method":"` + strings.Repeat("&", 680) + `"}`), []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","unexpected":true}`), []byte(`{"jsonrpc":"2.0","id":1,"fuzz-secret":1,"fuzz-secret":2,"method":"stave.ping"}`), []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping"} {}`), @@ -96,3 +97,12 @@ func protocolNestedSeed(depth int) []byte { } return append(output, '}') } + +func TestProtocolFuzzNestingBoundarySeeds(t *testing.T) { + if _, err := DecodeLine(protocolNestedSeed(64), maxProtocolFuzzBytes); err != nil { + t.Fatalf("accepted boundary seed rejected: %v", err) + } + if _, err := DecodeLine(protocolNestedSeed(65), maxProtocolFuzzBytes); err == nil { + t.Fatal("over-boundary seed accepted") + } +} From b25e216dacfb37344cc35c5c00fe8a4f068f6426 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:23:10 +1000 Subject: [PATCH 3/3] refactor(test): simplify bounded fuzz assertions --- config/fuzz_test.go | 51 ++++++++++++++++++++++++++++--------------- protocol/fuzz_test.go | 3 +-- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/config/fuzz_test.go b/config/fuzz_test.go index a846a28..b859b7a 100644 --- a/config/fuzz_test.go +++ b/config/fuzz_test.go @@ -16,33 +16,48 @@ func FuzzConfigParseCanonical(f *testing.F) { f.Add(seed) } f.Fuzz(func(t *testing.T, input []byte) { - if len(input) > maxConfigFuzzBytes { - return - } - if depth, valid := jsonNesting(input); valid && depth > 64 { + if !configFuzzInputInBounds(input) { return } config, err := Parse(input) if err != nil { - if strings.Contains(err.Error(), "fuzz-secret") { - t.Fatalf("config parser echoed a secret: %v", err) - } + assertConfigParseErrorSafe(t, err) return } - canonical := CanonicalJSON(config) - roundTrip, err := decodeCanonicalConfig(canonical) - if err != nil { - t.Fatalf("accepted config did not round trip: %v", err) - } - if !bytes.Equal(canonical, CanonicalJSON(roundTrip)) { - t.Fatalf("accepted config canonical encoding was unstable") - } - if HashString(config) != HashString(roundTrip) { - t.Fatalf("accepted config canonical hash was unstable") - } + assertConfigCanonicalRoundTrip(t, config) }) } +func configFuzzInputInBounds(input []byte) bool { + if len(input) > maxConfigFuzzBytes { + return false + } + depth, valid := jsonNesting(input) + return !valid || depth <= 64 +} + +func assertConfigParseErrorSafe(t *testing.T, err error) { + t.Helper() + if strings.Contains(err.Error(), "fuzz-secret") { + t.Fatalf("config parser echoed a secret: %v", err) + } +} + +func assertConfigCanonicalRoundTrip(t *testing.T, config Config) { + t.Helper() + canonical := CanonicalJSON(config) + roundTrip, err := decodeCanonicalConfig(canonical) + if err != nil { + t.Fatalf("accepted config did not round trip: %v", err) + } + if !bytes.Equal(canonical, CanonicalJSON(roundTrip)) { + t.Fatalf("accepted config canonical encoding was unstable") + } + if HashString(config) != HashString(roundTrip) { + t.Fatalf("accepted config canonical hash was unstable") + } +} + // decodeCanonicalConfig decodes a resolved Config. Parse consumes sparse // layers and applies Defaults, so it is intentionally not this decoder. func decodeCanonicalConfig(data []byte) (Config, error) { diff --git a/protocol/fuzz_test.go b/protocol/fuzz_test.go index f8450b9..57ae4bc 100644 --- a/protocol/fuzz_test.go +++ b/protocol/fuzz_test.go @@ -79,8 +79,7 @@ func decodeJSONValue(value []byte) (any, bool) { if err := decoder.Decode(&decoded); err != nil { return nil, false } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { + if decoder.Decode(new(any)) != io.EOF { return nil, false } return decoded, true