feat(verify): save and replay fuzz corpora
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadBasicKernel(t *testing.T) *Kernel {
|
||||
t.Helper()
|
||||
k, err := Load("../testdata/verify/basic_amd64.s")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
t.Cleanup(k.Close)
|
||||
return k
|
||||
}
|
||||
|
||||
func TestReplayEntry(t *testing.T) {
|
||||
k := loadBasicKernel(t)
|
||||
|
||||
e := CorpusEntry{Func: "add", Args: []CorpusArg{
|
||||
{Kind: "int", Value: "2"},
|
||||
{Kind: "int", Value: "3"},
|
||||
}}
|
||||
out, err := k.ReplayEntry("add", e)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplayEntry: %v", err)
|
||||
}
|
||||
if got := int64(GetUint64(out, 16)); got != 5 {
|
||||
t.Errorf("replay add(2, 3) = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorpusRoundTrip(t *testing.T) {
|
||||
k := loadBasicKernel(t)
|
||||
|
||||
e := CorpusEntry{Func: "add", Args: []CorpusArg{
|
||||
{Kind: "int", Value: "20"},
|
||||
{Kind: "int", Value: "22"},
|
||||
}}
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back CorpusEntry
|
||||
if err := json.Unmarshal(data, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
out, err := k.ReplayEntry("add", back)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplayEntry: %v", err)
|
||||
}
|
||||
if got := int64(GetUint64(out, 16)); got != 42 {
|
||||
t.Errorf("round-trip replay = %d, want 42", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenDualArgsEntryReplayable checks that the entry recorded alongside a
|
||||
// generated input replays to the same observable call.
|
||||
func TestGenDualArgsEntryReplayable(t *testing.T) {
|
||||
k := loadBasicKernel(t)
|
||||
|
||||
sig, ok := parseFuncSig("// func add(a, b int) int")
|
||||
if !ok {
|
||||
t.Fatal("parseFuncSig failed")
|
||||
}
|
||||
_, _, bufs, entry := genDualArgs(rand.New(rand.NewSource(1)), sig, 24)
|
||||
if len(entry.Args) != 2 || entry.Args[0].Kind != "int" {
|
||||
t.Fatalf("unexpected entry: %+v", entry)
|
||||
}
|
||||
out, err := k.ReplayEntry("add", entry)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplayEntry: %v", err)
|
||||
}
|
||||
want := int64(GetUint64(out, 16))
|
||||
got := entryInt(t, entry.Args[0]) + entryInt(t, entry.Args[1])
|
||||
if got != want {
|
||||
t.Errorf("replayed sum = %d, want %d", want, got)
|
||||
}
|
||||
runtime.KeepAlive(bufs)
|
||||
}
|
||||
|
||||
func entryInt(t *testing.T, a CorpusArg) int64 {
|
||||
t.Helper()
|
||||
v, err := strconv.ParseUint(a.Value, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("entry value %q: %v", a.Value, err)
|
||||
}
|
||||
return int64(v)
|
||||
}
|
||||
|
||||
// TestFuzzHookSavesFailures fuzzes add against the go-tool-asm build of a
|
||||
// sub kernel with the same signature, so every iteration mismatches (safely:
|
||||
// both kernels read only their own arguments) and the hook must record
|
||||
// replayable entries.
|
||||
func TestFuzzHookSavesFailures(t *testing.T) {
|
||||
src := `#include "textflag.h"
|
||||
|
||||
// func add(a, b int) int
|
||||
TEXT ·add(SB), NOSPLIT, $0-24
|
||||
MOVQ a+0(FP), AX
|
||||
ADDQ b+8(FP), AX
|
||||
MOVQ AX, ret+16(FP)
|
||||
RET
|
||||
|
||||
// func sub(a, b int) int
|
||||
TEXT ·sub(SB), NOSPLIT, $0-24
|
||||
MOVQ a+0(FP), AX
|
||||
SUBQ b+8(FP), AX
|
||||
MOVQ AX, ret+16(FP)
|
||||
RET
|
||||
`
|
||||
dir := t.TempDir()
|
||||
file := dir + "/addsub_test_amd64.s"
|
||||
if err := os.WriteFile(file, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write kernel: %v", err)
|
||||
}
|
||||
k, err := Load(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
t.Cleanup(k.Close)
|
||||
|
||||
sig, ok := parseFuncSig("// func add(a, b int) int")
|
||||
if !ok {
|
||||
t.Fatal("parseFuncSig failed")
|
||||
}
|
||||
gt, err := GroundTruth(file)
|
||||
if err != nil {
|
||||
t.Skipf("go tool asm unavailable: %v", err)
|
||||
}
|
||||
|
||||
var saved []CorpusEntry
|
||||
res := k.FuzzFuncHook("add", sig, gt["sub"], 5, 42, func(e CorpusEntry) {
|
||||
saved = append(saved, e)
|
||||
})
|
||||
if res.Mismatches == 0 {
|
||||
t.Fatal("expected mismatches against the sub reference")
|
||||
}
|
||||
if len(saved) == 0 {
|
||||
t.Fatal("hook saved no entries despite mismatches")
|
||||
}
|
||||
for _, e := range saved {
|
||||
if e.Func != "add" || len(e.Args) != 2 {
|
||||
t.Errorf("bad entry: %+v", e)
|
||||
}
|
||||
if _, err := k.ReplayEntry(e.Func, e); err != nil {
|
||||
t.Errorf("saved entry does not replay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-3
@@ -4,6 +4,7 @@
|
||||
package verify
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
@@ -40,6 +41,24 @@ func (r FuzzResult) String() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// CorpusArg is one replayable argument of a corpus entry.
|
||||
type CorpusArg struct {
|
||||
Kind string `json:"kind"` // "slice", "ptr", "int", "scalar"
|
||||
Len int `json:"len,omitempty"` // slice: declared length in elements
|
||||
Data string `json:"data,omitempty"` // slice/ptr: hex-encoded buffer content
|
||||
Value string `json:"value,omitempty"` // int/scalar: decimal value
|
||||
}
|
||||
|
||||
// CorpusEntry is a replayable fuzz input: the logical arguments of one
|
||||
// generated call, stored as JSON. A raw argument block replays nowhere
|
||||
// (its pointers point into mappings that died with the process), so the
|
||||
// corpus records buffer contents and scalars instead and ReplayEntry
|
||||
// rebuilds a live block from them.
|
||||
type CorpusEntry struct {
|
||||
Func string `json:"func"`
|
||||
Args []CorpusArg `json:"args"`
|
||||
}
|
||||
|
||||
// funcSig is a parsed // func signature from the assembly source.
|
||||
type funcSig struct {
|
||||
name string
|
||||
@@ -157,6 +176,13 @@ func ExtractSignatures(src string) map[string]funcSig {
|
||||
// The signature comment must appear immediately above the TEXT directive
|
||||
// in the source (the conventional Go assembly layout).
|
||||
func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations int, seed int64) FuzzResult {
|
||||
return k.FuzzFuncHook(name, sig, goCode, iterations, seed, nil)
|
||||
}
|
||||
|
||||
// FuzzFuncHook is FuzzFunc with a hook invoked for every failing input (a
|
||||
// crash or a mismatch), receiving a replayable corpus entry. A nil hook
|
||||
// behaves exactly like FuzzFunc.
|
||||
func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iterations int, seed int64, onSave func(CorpusEntry)) FuzzResult {
|
||||
result := FuzzResult{Func: name, Iterations: iterations}
|
||||
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
@@ -181,7 +207,8 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
|
||||
// Generate inputs and build TWO independent arg blocks (one per
|
||||
// version) so that functions which write to their arguments
|
||||
// (e.g. histogram increments) don't corrupt the other's input.
|
||||
gasmArgs, goArgs, bufs := genDualArgs(rng, sig, fl.Args)
|
||||
gasmArgs, goArgs, bufs, entry := genDualArgs(rng, sig, fl.Args)
|
||||
entry.Func = name
|
||||
|
||||
// Save the current input for crash diagnostics.
|
||||
result.CrashInput = gasmArgs
|
||||
@@ -193,6 +220,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
|
||||
if result.FirstFail == "" {
|
||||
result.FirstFail = fmt.Sprintf("iter %d: gasm call: %v", i, err)
|
||||
}
|
||||
if onSave != nil {
|
||||
onSave(entry)
|
||||
}
|
||||
runtime.KeepAlive(bufs)
|
||||
continue
|
||||
}
|
||||
@@ -204,6 +234,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
|
||||
if result.FirstFail == "" {
|
||||
result.FirstFail = fmt.Sprintf("iter %d: go call: %v", i, err)
|
||||
}
|
||||
if onSave != nil {
|
||||
onSave(entry)
|
||||
}
|
||||
runtime.KeepAlive(bufs)
|
||||
continue
|
||||
}
|
||||
@@ -219,6 +252,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
|
||||
if result.FirstFail == "" {
|
||||
result.FirstFail = fmt.Sprintf("iter %d: output mismatch at result offset %d", i, resultOff)
|
||||
}
|
||||
if onSave != nil {
|
||||
onSave(entry)
|
||||
}
|
||||
} else {
|
||||
result.Matches++
|
||||
}
|
||||
@@ -230,7 +266,7 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
|
||||
// genDualArgs generates two independent ABI0 argument blocks (for gasm and
|
||||
// go) with identical logical content but separate backing buffers, so that
|
||||
// functions which write to their arguments don't corrupt the other's input.
|
||||
func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []byte, bufs [][]byte) {
|
||||
func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []byte, bufs [][]byte, entry CorpusEntry) {
|
||||
gasmArgs = make([]byte, argSize)
|
||||
goArgs = make([]byte, argSize)
|
||||
off := 0
|
||||
@@ -267,6 +303,11 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
|
||||
putU64(goArgs, off+16, uint64(declaredLen))
|
||||
off += 24
|
||||
sliceIdx++
|
||||
entry.Args = append(entry.Args, CorpusArg{
|
||||
Kind: "slice",
|
||||
Len: declaredLen,
|
||||
Data: hex.EncodeToString(buf1[:n*elemSize]),
|
||||
})
|
||||
|
||||
case strings.HasPrefix(p.typ, "*["):
|
||||
nElem := arrayLen(p.typ)
|
||||
@@ -280,21 +321,88 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
|
||||
putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0]))
|
||||
putPtr(goArgs, off, unsafe.Pointer(&buf2[0]))
|
||||
off += 8
|
||||
entry.Args = append(entry.Args, CorpusArg{
|
||||
Kind: "ptr",
|
||||
Data: hex.EncodeToString(buf1),
|
||||
})
|
||||
|
||||
case p.typ == "int" || p.typ == "uint" || p.typ == "int64" || p.typ == "uint64":
|
||||
v := uint64(rng.Intn(256))
|
||||
putU64(gasmArgs, off, v)
|
||||
putU64(goArgs, off, v)
|
||||
off += 8
|
||||
entry.Args = append(entry.Args, CorpusArg{Kind: "int", Value: strconv.FormatUint(v, 10)})
|
||||
|
||||
default:
|
||||
v := rng.Uint64()
|
||||
putU64(gasmArgs, off, v)
|
||||
putU64(goArgs, off, v)
|
||||
off += 8
|
||||
entry.Args = append(entry.Args, CorpusArg{Kind: "scalar", Value: strconv.FormatUint(v, 10)})
|
||||
}
|
||||
}
|
||||
return gasmArgs, goArgs, bufs
|
||||
return gasmArgs, goArgs, bufs, entry
|
||||
}
|
||||
|
||||
// ReplayEntry rebuilds the argument block of a corpus entry and invokes the
|
||||
// named function once, returning the argument block after the call. Slice
|
||||
// buffers get the same safety padding the fuzzer uses, so over-reads that
|
||||
// were harmless during the original run stay harmless on replay.
|
||||
func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) {
|
||||
fl, err := k.Func(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := make([]byte, fl.Args)
|
||||
var bufs [][]byte
|
||||
off := 0
|
||||
for _, a := range e.Args {
|
||||
switch a.Kind {
|
||||
case "slice":
|
||||
data, err := hex.DecodeString(a.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("corpus: slice data: %w", err)
|
||||
}
|
||||
buf := make([]byte, len(data)+8192)
|
||||
copy(buf, data)
|
||||
bufs = append(bufs, buf)
|
||||
if off+24 > len(args) {
|
||||
return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name)
|
||||
}
|
||||
putPtr(args, off, unsafe.Pointer(&buf[0]))
|
||||
putU64(args, off+8, uint64(a.Len))
|
||||
putU64(args, off+16, uint64(a.Len))
|
||||
off += 24
|
||||
|
||||
case "ptr":
|
||||
data, err := hex.DecodeString(a.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("corpus: ptr data: %w", err)
|
||||
}
|
||||
buf := make([]byte, max(len(data), 8))
|
||||
copy(buf, data)
|
||||
bufs = append(bufs, buf)
|
||||
if off+8 > len(args) {
|
||||
return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name)
|
||||
}
|
||||
putPtr(args, off, unsafe.Pointer(&buf[0]))
|
||||
off += 8
|
||||
|
||||
default: // "int", "scalar"
|
||||
v, err := strconv.ParseUint(a.Value, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("corpus: %s value: %w", a.Kind, err)
|
||||
}
|
||||
if off+8 > len(args) {
|
||||
return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name)
|
||||
}
|
||||
putU64(args, off, v)
|
||||
off += 8
|
||||
}
|
||||
}
|
||||
out, err := k.CallFunc(name, args)
|
||||
runtime.KeepAlive(bufs)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func elemSizeFor(sliceType string) int {
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ func (k *Kernel) FuzzFuncChecked(name string, sig funcSig, iterations int, seed
|
||||
|
||||
violations := 0
|
||||
for i := range iterations {
|
||||
gasmArgs, _, bufs := genDualArgs(rng, sig, fl.Args)
|
||||
gasmArgs, _, bufs, _ := genDualArgs(rng, sig, fl.Args)
|
||||
result.CrashInput = gasmArgs
|
||||
|
||||
_, report, err := k.CallFuncChecked(name, gasmArgs)
|
||||
|
||||
Reference in New Issue
Block a user