diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..a95c9be --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Petr BalvĂ­n (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", + } + for _, s := range seeds { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + tree, err := Parse(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 := Parse(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) + } +}