Files
zap/composite_fuzz_test.go
Abhishek Krishna 749e2273ee test: add composite + schema + opcode fuzz targets
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.
2026-06-01 12:10:51 +00:00

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)
}
})
}