Files
interpres/fuzz_test.go
petrbalvin bef1d3fbd9
Test / test (push) Successful in 1m31s
test: add FuzzMarshal, golden messages, synctest cancellation and cross smoke
Assisted-by: GLM 5.3 Flash
2026-09-22 01:03:27 +02:00

192 lines
5.5 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"math"
"reflect"
"testing"
"time"
)
// FuzzParse drives the parser with arbitrary input and holds it to the
// round-trip invariant: every document Parse accepts must survive its own
// re-emission. Marshal of the parsed tree must succeed, the emitted document
// must parse again, and the re-parsed tree must equal the original one.
func FuzzParse(f *testing.F) {
seeds := []string{
"",
"title = \"interpres\"\n",
"[server]\nhost = \"localhost\"\nport = 8080\n\n[server.tls]\nenabled = true\n",
"[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n",
"inline = { a = 1, b = [2, 3], c = { d = 4 } }\n",
"arr = [1, 2.5, \"three\", true, 1979-05-27T07:32:00Z]\n",
"mix = [1, {a = 2}, \"x\"]\n",
"when = 1979-05-27T07:32:00Z\nlocal = 1979-05-27T07:32:00.999\nd = 1979-05-27\nt = 07:32:00\n",
"multi = \"\"\"\nlines\n\"\"\"\nlit = 'literal'\n",
"esc = \"\\u0000\\t\\n\\\"\\\\\"\n",
"neg = -0.0\nnan = nan\ninf = -inf\nexp = 1e6\n",
"\"quoted key\" = 'value'\n'1979-05-27' = 1\na.b.c = { d = \"dotted\" }\n",
"hex = 0xFF\noct = 0o755\nbin = 0b1010\nsep = 1_000_000\n",
"x = \"unterminated\n",
"[a]\n[a]\n",
"n = 0x1_0000_0000_0000_0000\n",
// TOML 1.1 forms.
"t = 13:37\ndt = 1979-05-27T07:32\nodt = 1979-05-27 07:32Z\n",
"esc = \"\\e\\x41\\x7f\\x00\"\n",
"m = {\n\ta = 1,\n\tb = [1, 2,],\n\tc = { d = 2 },\n} # close\n",
}
for _, s := range seeds {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
tree, err := ParseMap(data)
if err != nil {
return
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal of a parsed tree failed: %v\ntree: %#v", err, tree)
}
re, err := ParseMap(out)
if err != nil {
t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out)
}
if !tomlEqual(tree, re) {
t.Fatalf("round-trip changed the tree\ninput: %q\ndoc:\n%s\nwas: %#v\nnow: %#v", data, out, tree, re)
}
})
}
// tomlEqual reports whether two parsed trees are equal as TOML values. It
// differs from reflect.DeepEqual where DeepEqual is wrong for this domain:
// NaN compares equal to itself, date-times compare by their canonical TOML
// rendering so two parses of one document stay equal, and the local variants
// compare through their String form, which fully determines the value.
func tomlEqual(a, b any) bool {
switch av := a.(type) {
case nil:
return b == nil
case float64:
bv, ok := b.(float64)
return ok && (av == bv || (math.IsNaN(av) && math.IsNaN(bv)))
case time.Time:
bv, ok := b.(time.Time)
return ok && av.Format(time.RFC3339Nano) == bv.Format(time.RFC3339Nano)
case LocalDateTime:
bv, ok := b.(LocalDateTime)
return ok && av.String() == bv.String()
case LocalDate:
bv, ok := b.(LocalDate)
return ok && av.String() == bv.String()
case LocalTime:
bv, ok := b.(LocalTime)
return ok && av.String() == bv.String()
case []any:
bv, ok := b.([]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !tomlEqual(av[i], bv[i]) {
return false
}
}
return true
case []map[string]any:
bv, ok := b.([]map[string]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !tomlEqual(av[i], bv[i]) {
return false
}
}
return true
case map[string]any:
bv, ok := b.(map[string]any)
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
other, ok := bv[k]
if !ok || !tomlEqual(v, other) {
return false
}
}
return true
default:
return reflect.DeepEqual(a, b)
}
}
// FuzzMarshal drives the encoder with generated Go values and holds it to
// the same round-trip invariant FuzzParse holds the parser to: a value built
// only of encodable kinds must marshal, the document must re-parse, and the
// tree must equal the value it came from.
func FuzzMarshal(f *testing.F) {
seeds := [][]byte{
{},
{0, 0, 1, 2},
{1, 1, 2, 3, 2, 2, 3, 4},
{0, 3, 1, 9, 3, 3, 2, 8, 1, 0, 1, 7},
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
v := fuzzValue(data)
out, err := Marshal(v)
if err != nil {
t.Fatalf("marshal of an encodable value failed: %v\nvalue: %#v", err, v)
}
tree, err := ParseMap(out)
if err != nil {
t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out)
}
if !tomlEqual(v, tree) {
t.Fatalf("round-trip changed the value\nvalue: %#v\ndoc:\n%s\ntree: %#v", v, out, tree)
}
})
}
// fuzzKeys is the fixed key pool the generated values draw from, so keys are
// always valid bare keys and repeat often.
var fuzzKeys = []string{"alpha", "beta", "gamma", "delta"}
// fuzzValue builds a map[string]any of encodable kinds from data: integers,
// positive floats, short strings, nested tables and scalar arrays. The bytes
// decide the shape deterministically.
func fuzzValue(data []byte) map[string]any {
root := map[string]any{}
cur := root
depth := 0
for i := 0; i+3 < len(data); i += 4 {
key := fuzzKeys[int(data[i])%len(fuzzKeys)]
switch data[i+1] % 5 {
case 0:
cur[key] = int64(data[i+2])<<8 | int64(data[i+3])
case 1:
cur[key] = float64(int(data[i+2])%1000)/8.0 + 0.125
case 2:
cur[key] = string(rune('a' + int(data[i+2])%26))
case 3:
cur[key] = []any{
int64(data[i+2]),
float64(int(data[i+3])%100)/4.0 + 0.25,
string(rune('a' + int(data[i+3])%26)),
}
case 4:
if depth < 6 {
next := map[string]any{}
cur[key] = next
cur = next
depth++
}
}
}
return root
}