141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
package interpres
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"reflect"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fuzzNested struct {
|
||
|
|
X int `toml:"x"`
|
||
|
|
Y string `toml:"y"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type fuzzDoc struct {
|
||
|
|
Num int `toml:"num"`
|
||
|
|
Flt float64 `toml:"flt"`
|
||
|
|
Str string `toml:"str"`
|
||
|
|
Flag bool `toml:"flag"`
|
||
|
|
Small uint8 `toml:"small"`
|
||
|
|
When time.Time `toml:"when"`
|
||
|
|
Tags []string `toml:"tags"`
|
||
|
|
Lims map[string]any `toml:"lims"`
|
||
|
|
Tab fuzzNested `toml:"tab"`
|
||
|
|
Arr []fuzzNested `toml:"arr"`
|
||
|
|
Other string `toml:"other"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// fuzzStmts is the statement pool the generated documents draw from: every
|
||
|
|
// destination kind the targeted parse handles, beside the shapes that make
|
||
|
|
// it fall back (overflow, unknown tables, duplicate keys).
|
||
|
|
var fuzzStmts = []string{
|
||
|
|
`num = 1`, `num = 300`, `small = 300`, `small = 7`,
|
||
|
|
`flt = 2.5`, `str = "x"`, `flag = true`,
|
||
|
|
`when = 1979-05-27T07:32:00Z`,
|
||
|
|
`tags = ["a", "b"]`, `tags = []`, `lims = { k = 1 }`,
|
||
|
|
`[tab]`, `tab.x = 1`, `tab.y = "s"`, `x = 2`, `y = "t"`,
|
||
|
|
`[[arr]]`, `x = 3`, `y = "u"`,
|
||
|
|
`[tab.nested]`, `x = 4`,
|
||
|
|
`other = "o"`, `zz = 1`, `[zz]`, `k = 1`,
|
||
|
|
`num = 2`,
|
||
|
|
}
|
||
|
|
|
||
|
|
func fuzzDocument(data []byte) []byte {
|
||
|
|
var b strings.Builder
|
||
|
|
for i, by := range data {
|
||
|
|
if i > 0 {
|
||
|
|
b.WriteByte('\n')
|
||
|
|
}
|
||
|
|
b.WriteString(fuzzStmts[int(by)%len(fuzzStmts)])
|
||
|
|
}
|
||
|
|
return []byte(b.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
// treeDecodeInto is the reference decode: the ordinary tree path, non-strict
|
||
|
|
// like the fuzz decode; the strict contracts have their own deterministic
|
||
|
|
// tests.
|
||
|
|
func treeDecodeInto(data []byte, v any) error {
|
||
|
|
dec := newDecoder()
|
||
|
|
tree, _, err := parseWithOptions(context.Background(), data, parseOptions{}, false)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return dec.decode(tree, v)
|
||
|
|
}
|
||
|
|
|
||
|
|
// decodeFinding normalises an error for the comparison. Decode-stage
|
||
|
|
// findings several tables may produce (an unknown field, a missing required
|
||
|
|
// key) compare as their class alone: the tree decode picks the reporting
|
||
|
|
// table by map order and so does not promise one. Everything else compares
|
||
|
|
// as its exact text.
|
||
|
|
func decodeFinding(err error) string {
|
||
|
|
if err == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if de, ok := errors.AsType[*DecodeError](err); ok {
|
||
|
|
if strings.Contains(de.Err.Error(), "unknown field") {
|
||
|
|
return "unknown"
|
||
|
|
}
|
||
|
|
if strings.Contains(de.Err.Error(), "missing required key") {
|
||
|
|
return "required"
|
||
|
|
}
|
||
|
|
return de.Path.String() + ": " + de.Err.Error()
|
||
|
|
}
|
||
|
|
return err.Error()
|
||
|
|
}
|
||
|
|
|
||
|
|
// FuzzTargetedDecode holds the targeted parse to the tree decode as its
|
||
|
|
// reference: for every generated document the two paths must agree on the
|
||
|
|
// error class and on the decoded value.
|
||
|
|
func FuzzTargetedDecode(f *testing.F) {
|
||
|
|
seeds := []string{
|
||
|
|
"num = 1\nstr = \"x\"\n[tab]\nx = 2\n[[arr]]\nx = 3\n",
|
||
|
|
"small = 300\n",
|
||
|
|
"[tab]\ntab.x = 1\n",
|
||
|
|
"lims = { k = 1 }\ntags = [\"a\"]\n",
|
||
|
|
"[[arr]]\ny = \"u\"\n[zz]\nk = 1\n",
|
||
|
|
}
|
||
|
|
for _, s := range seeds {
|
||
|
|
f.Add([]byte(s))
|
||
|
|
}
|
||
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||
|
|
doc := fuzzDocument(data)
|
||
|
|
var tgt fuzzDoc
|
||
|
|
tgtErr := NewDecoder().Decode(doc, &tgt)
|
||
|
|
if tgtErr != nil {
|
||
|
|
// A document with several decode-stage findings reports a different
|
||
|
|
// one per run (the tree decode walks its maps in random order), so the
|
||
|
|
// reference gets a few chances to produce the finding the targeted
|
||
|
|
// side carries. The targeted error is either the tree's own or the
|
||
|
|
// fallback already reran the tree.
|
||
|
|
for i := range 8 {
|
||
|
|
var ref fuzzDoc
|
||
|
|
refErr := treeDecodeInto(doc, &ref)
|
||
|
|
if refErr == nil {
|
||
|
|
t.Fatalf("reference succeeded on retry %d, targeted failed: %v\ndoc:\n%s", i, tgtErr, doc)
|
||
|
|
}
|
||
|
|
if decodeFinding(refErr) == decodeFinding(tgtErr) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if i == 7 {
|
||
|
|
t.Fatalf("errors disagree after retries:\ntargeted: %v\nlast tree: %v\ndoc:\n%s", tgtErr, refErr, doc)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var ref fuzzDoc
|
||
|
|
refErr := treeDecodeInto(doc, &ref)
|
||
|
|
if refErr != nil {
|
||
|
|
t.Fatalf("reference failed, targeted succeeded: %v\ndoc:\n%s", refErr, doc)
|
||
|
|
}
|
||
|
|
if !reflect.DeepEqual(ref, tgt) {
|
||
|
|
t.Fatalf("values disagree:\ntree: %#v\ntargeted: %#v\ndoc:\n%s", ref, tgt, doc)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|