mirror of
https://github.com/luxfi/zap.git
synced 2026-07-27 05:54:26 +00:00
The existing fuzz_test.go covers Parse, a flat-object Roundtrip,
malformed headers, and large payloads. This commit adds four targets
that probe shapes and code paths real consumers depend on but the
current corpus does not:
* FuzzZAPCompositeRoundtrip exercises the full Builder pipeline on a
composite shape: an outer object with a nested object, a uint32 list,
a uint64 list, a text field, and a bytes field. Every consumer
(MCP tool calls, consensus messages, agent payments) ships composites
on the wire, so a roundtrip target for them catches the relative-
offset and deferred-data math the flat fuzz cannot.
* FuzzZAPSchemaBuilder drives schema.go's StructBuilder against
arbitrary type-tag sequences and checks the alignment + size
invariants. schema.go had no fuzz coverage; alignment math is
exactly where a generative target pays.
* FuzzZAPFloatRoundtrip verifies Float32/Float64 fields survive
Builder -> Parse with bitwise fidelity (NaN, +/-Inf, subnormals).
The existing roundtrip fuzz covered only ints + text + bool.
* FuzzProcedureOpcode (zapclient) asserts ProcedureOpcode's documented
invariants on arbitrary names: empty -> error, low byte = 0,
high byte not in {0x00, 0xFF}, deterministic.
All four targets pass their seed corpus and run cleanly for tens of
seconds of -fuzztime locally without finding a crash. Pure additive:
new files, no overlap with PRs #1, #2, #3.
307 lines
9.2 KiB
Go
307 lines
9.2 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zap
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
// FuzzZAPCompositeRoundtrip drives the Builder pipeline through a richer
|
|
// shape than FuzzZAPRoundtrip: an outer object containing a nested
|
|
// object, a list of uint32, a list of uint64, a text field, and a bytes
|
|
// field. After Builder.Finish + Parse, every field must read back equal
|
|
// to the input.
|
|
//
|
|
// This complements the existing fuzz targets, which only exercise a
|
|
// flat object (uint32/uint64/int32/bool/text). Composite shapes are
|
|
// what every real consumer (mcp tool calls, consensus messages, agent
|
|
// payments) ships on the wire, so a fuzz target that builds and reads
|
|
// them is the highest-value addition.
|
|
func FuzzZAPCompositeRoundtrip(f *testing.F) {
|
|
// Seed corpus.
|
|
f.Add(uint32(0), uint64(0), uint32(0), uint32(0), "", []byte{})
|
|
f.Add(uint32(1), uint64(2), uint32(3), uint32(4), "hello", []byte{0x01, 0x02, 0x03})
|
|
f.Add(uint32(0xFFFFFFFF), uint64(0xFFFFFFFFFFFFFFFF), uint32(7), uint32(11),
|
|
"unicode ☃ snowman", []byte{0x00, 0xFF, 0xAA, 0x55})
|
|
f.Add(uint32(42), uint64(0xDEADBEEFCAFEBABE), uint32(0), uint32(0),
|
|
"empty lists", []byte{})
|
|
f.Add(uint32(1), uint64(1), uint32(64), uint32(32),
|
|
"medium lists", []byte("abcdefghijklmnop"))
|
|
|
|
f.Fuzz(func(t *testing.T, innerU32 uint32, innerU64 uint64,
|
|
listU32Len uint32, listU64Len uint32, text string, blob []byte) {
|
|
// Bound the list lengths to keep the fuzz fast.
|
|
const maxLen = 256
|
|
listU32Len %= maxLen + 1
|
|
listU64Len %= maxLen + 1
|
|
|
|
// Bound the text and blob sizes too.
|
|
const maxBytes = 4096
|
|
if len(text) > maxBytes {
|
|
text = text[:maxBytes]
|
|
}
|
|
if len(blob) > maxBytes {
|
|
blob = blob[:maxBytes]
|
|
}
|
|
|
|
b := NewBuilder(256)
|
|
|
|
// Build the inner object first; offsets are needed for the outer.
|
|
inner := b.StartObject(16)
|
|
inner.SetUint32(0, innerU32)
|
|
inner.SetUint64(8, innerU64)
|
|
innerOff := inner.Finish()
|
|
|
|
// Build the uint32 list.
|
|
lb32 := b.StartList(4)
|
|
for i := uint32(0); i < listU32Len; i++ {
|
|
lb32.AddUint32(i ^ innerU32) // deterministic content
|
|
}
|
|
list32Off, list32Len := lb32.Finish()
|
|
|
|
// Build the uint64 list.
|
|
lb64 := b.StartList(8)
|
|
for i := uint32(0); i < listU64Len; i++ {
|
|
lb64.AddUint64(uint64(i) ^ innerU64)
|
|
}
|
|
list64Off, list64Len := lb64.Finish()
|
|
|
|
// Build the outer object referencing the inner + lists + scalars.
|
|
// Layout (offsets within outer's fixed section):
|
|
// 0 : Object (4 bytes, relative offset)
|
|
// 8 : List<u32> (8 bytes: offset+length)
|
|
// 16 : List<u64> (8 bytes: offset+length)
|
|
// 24 : Text (8 bytes: offset+length)
|
|
// 32 : Bytes (8 bytes: offset+length)
|
|
outer := b.StartObject(40)
|
|
outer.SetObject(0, innerOff)
|
|
outer.SetList(8, list32Off, list32Len)
|
|
outer.SetList(16, list64Off, list64Len)
|
|
outer.SetText(24, text)
|
|
outer.SetBytes(32, blob)
|
|
outer.FinishAsRoot()
|
|
|
|
data := b.Finish()
|
|
|
|
msg, err := Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("Parse failed on builder output: %v", err)
|
|
}
|
|
|
|
root := msg.Root()
|
|
|
|
// Inner object readback.
|
|
got := root.Object(0)
|
|
if got.IsNull() {
|
|
t.Fatalf("inner object null")
|
|
}
|
|
if v := got.Uint32(0); v != innerU32 {
|
|
t.Errorf("inner u32: got %d want %d", v, innerU32)
|
|
}
|
|
if v := got.Uint64(8); v != innerU64 {
|
|
t.Errorf("inner u64: got %x want %x", v, innerU64)
|
|
}
|
|
|
|
// uint32 list readback.
|
|
l32 := root.List(8)
|
|
if l32.Len() != int(listU32Len) {
|
|
t.Errorf("list32 length: got %d want %d", l32.Len(), listU32Len)
|
|
}
|
|
for i := uint32(0); i < listU32Len; i++ {
|
|
want := i ^ innerU32
|
|
if v := l32.Uint32(int(i)); v != want {
|
|
t.Errorf("list32[%d]: got %d want %d", i, v, want)
|
|
break
|
|
}
|
|
}
|
|
|
|
// uint64 list readback.
|
|
l64 := root.List(16)
|
|
if l64.Len() != int(listU64Len) {
|
|
t.Errorf("list64 length: got %d want %d", l64.Len(), listU64Len)
|
|
}
|
|
for i := uint32(0); i < listU64Len; i++ {
|
|
want := uint64(i) ^ innerU64
|
|
if v := l64.Uint64(int(i)); v != want {
|
|
t.Errorf("list64[%d]: got %d want %d", i, v, want)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Text readback.
|
|
if v := root.Text(24); v != text {
|
|
t.Errorf("text: got %q want %q", v, text)
|
|
}
|
|
|
|
// Bytes readback.
|
|
gotBlob := root.Bytes(32)
|
|
if len(gotBlob) != len(blob) {
|
|
t.Errorf("bytes len: got %d want %d", len(gotBlob), len(blob))
|
|
} else {
|
|
for i := range blob {
|
|
if gotBlob[i] != blob[i] {
|
|
t.Errorf("bytes[%d]: got %x want %x", i, gotBlob[i], blob[i])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// FuzzZAPSchemaBuilder exercises schema.go's StructBuilder against
|
|
// arbitrary field-type sequences. The invariant under test: the computed
|
|
// Size must equal the sum of each field's TypeSize plus the padding
|
|
// implied by alignment, and every recorded Field.Offset must be
|
|
// naturally aligned for its type.
|
|
//
|
|
// schema.go has zero fuzz coverage today; alignment math is exactly the
|
|
// kind of code that benefits most.
|
|
func FuzzZAPSchemaBuilder(f *testing.F) {
|
|
// Each byte in the seed is interpreted as a type tag (mod 11).
|
|
f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
|
|
f.Add([]byte{2, 4, 2, 4, 2, 4})
|
|
f.Add([]byte{6, 0, 6, 0, 6, 0}) // text after bools
|
|
f.Add([]byte{}) // empty struct
|
|
f.Add([]byte{10}) // single bytes field
|
|
|
|
f.Fuzz(func(t *testing.T, tags []byte) {
|
|
const maxFields = 256
|
|
if len(tags) > maxFields {
|
|
tags = tags[:maxFields]
|
|
}
|
|
|
|
sb := NewStructBuilder("Fuzz")
|
|
// Track the size each field should occupy + alignment requirement.
|
|
type expect struct {
|
|
typ Type
|
|
align int
|
|
}
|
|
var expects []expect
|
|
|
|
for _, tag := range tags {
|
|
switch tag % 11 {
|
|
case 0:
|
|
sb.Bool("b")
|
|
expects = append(expects, expect{TypeBool, 1})
|
|
case 1:
|
|
sb.Int32("i32")
|
|
expects = append(expects, expect{TypeInt32, 4})
|
|
case 2:
|
|
sb.Uint32("u32")
|
|
expects = append(expects, expect{TypeUint32, 4})
|
|
case 3:
|
|
sb.Int64("i64")
|
|
expects = append(expects, expect{TypeInt64, 8})
|
|
case 4:
|
|
sb.Uint64("u64")
|
|
expects = append(expects, expect{TypeUint64, 8})
|
|
case 5:
|
|
sb.Float64("f64")
|
|
expects = append(expects, expect{TypeFloat64, 8})
|
|
case 6:
|
|
sb.Text("t")
|
|
expects = append(expects, expect{TypeText, 4})
|
|
case 7:
|
|
sb.Bytes("by")
|
|
expects = append(expects, expect{TypeBytes, 4})
|
|
case 8:
|
|
sb.List("l", TypeUint32)
|
|
expects = append(expects, expect{TypeList, 4})
|
|
case 9:
|
|
sb.Struct("s", "Other")
|
|
expects = append(expects, expect{TypeStruct, 4})
|
|
case 10:
|
|
sb.Bytes("by2")
|
|
expects = append(expects, expect{TypeBytes, 4})
|
|
}
|
|
}
|
|
|
|
built := sb.Build()
|
|
|
|
// Invariant 1: number of fields matches.
|
|
if len(built.Fields) != len(expects) {
|
|
t.Fatalf("field count: got %d want %d", len(built.Fields), len(expects))
|
|
}
|
|
|
|
// Invariant 2: every recorded offset is naturally aligned for the
|
|
// field's expected alignment.
|
|
for i, fld := range built.Fields {
|
|
a := expects[i].align
|
|
if a > 1 && fld.Offset%a != 0 {
|
|
t.Errorf("field %d (%v) offset %d not aligned to %d",
|
|
i, fld.Type, fld.Offset, a)
|
|
}
|
|
if fld.Type != expects[i].typ {
|
|
t.Errorf("field %d type: got %v want %v", i, fld.Type, expects[i].typ)
|
|
}
|
|
}
|
|
|
|
// Invariant 3: Size is final-aligned to 8 (the StructBuilder applies
|
|
// a trailing align(8) before recording Size).
|
|
if built.Size%8 != 0 {
|
|
t.Errorf("Size %d not aligned to 8", built.Size)
|
|
}
|
|
|
|
// Invariant 4: Size is at least the end-of-last-field byte.
|
|
if len(built.Fields) > 0 {
|
|
last := built.Fields[len(built.Fields)-1]
|
|
minSize := last.Offset + TypeSize(last.Type)
|
|
if built.Size < minSize {
|
|
t.Errorf("Size %d < last field end %d", built.Size, minSize)
|
|
}
|
|
}
|
|
|
|
// Invariant 5: empty schema must have Size == 0 and no fields.
|
|
if len(tags) == 0 {
|
|
if built.Size != 0 {
|
|
t.Errorf("empty schema Size: got %d want 0", built.Size)
|
|
}
|
|
if len(built.Fields) != 0 {
|
|
t.Errorf("empty schema fields: got %d want 0", len(built.Fields))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// FuzzZAPFloatRoundtrip verifies that float32 and float64 fields survive
|
|
// a Builder -> Parse round-trip with bitwise fidelity (including for NaN
|
|
// / +/-Inf / subnormal values). The existing roundtrip fuzz covers only
|
|
// integer + text + bool.
|
|
func FuzzZAPFloatRoundtrip(f *testing.F) {
|
|
f.Add(uint32(0), uint64(0)) // +/-0
|
|
f.Add(math.Float32bits(float32(math.Inf(1))), math.Float64bits(math.Inf(-1)))
|
|
f.Add(math.Float32bits(float32(math.NaN())), math.Float64bits(math.NaN()))
|
|
f.Add(uint32(0x7F7FFFFF), uint64(0x7FEFFFFFFFFFFFFF)) // max finite
|
|
f.Add(uint32(0x00800000), uint64(0x0010000000000000)) // min normal
|
|
f.Add(uint32(0x00000001), uint64(0x0000000000000001)) // min subnormal
|
|
|
|
f.Fuzz(func(t *testing.T, f32Bits uint32, f64Bits uint64) {
|
|
f32 := math.Float32frombits(f32Bits)
|
|
f64 := math.Float64frombits(f64Bits)
|
|
|
|
b := NewBuilder(64)
|
|
ob := b.StartObject(16)
|
|
ob.SetFloat32(0, f32)
|
|
ob.SetFloat64(8, f64)
|
|
ob.FinishAsRoot()
|
|
|
|
msg, err := Parse(b.Finish())
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
root := msg.Root()
|
|
|
|
// Compare via bits because NaN != NaN under regular ==.
|
|
gotF32Bits := math.Float32bits(root.Float32(0))
|
|
if gotF32Bits != f32Bits {
|
|
t.Errorf("float32 bits: got %08x want %08x", gotF32Bits, f32Bits)
|
|
}
|
|
gotF64Bits := math.Float64bits(root.Float64(8))
|
|
if gotF64Bits != f64Bits {
|
|
t.Errorf("float64 bits: got %016x want %016x", gotF64Bits, f64Bits)
|
|
}
|
|
})
|
|
}
|