Files
zap/fuzz_test.go
T
Hanzo AI 3fac1c82bf feat(zap): v0.7.2 — Object.ListStride per-stride clamp + List.Len SAFETY doc + FuzzParse
LP-023 Red round 3 follow-ups:

NEW-V1 follow-up: tighter per-element stride clamp via Object.ListStride.
The bare Object.List uses the permissive `length <= len(data)` baseline
(wire layer cannot know per-accessor stride). Callers that know the
element width can opt into the tighter `length * minStride <= bufRem`
clamp via ListStride(off, stride), rejecting poisoned length values up
front instead of relying on per-element bounds checks.

NEW-V1 docstring: List.Len() now carries the SAFETY caveat — callers MUST
NOT pre-allocate via make([]T, l.Len()) without an independent bound.

FuzzParse (Red follow-up #3): round-trip property fuzzer pinning the
Parse↔Bytes contract: msg.Bytes() == data[:msg.Size()], Parse is
idempotent on its own output, Version() always in {Version1, Version2}.
1M+ execs clean on M1 Max with 20s fuzztime; seed corpus includes
adversarial buffers that exercised RED-HIGH-1/2/3.

Tests:
- TestNewV1_ListStrideTighterClamp — poisoned length=100 passes bare
  baseline but ListStride(0, 4) rejects (400 > bufRem)
- TestNewV1_ListStrideAcceptsHonestLength — honest length=5 stride=4
  buffer must pass

All existing tests still pass (RedRound2 HIGH-1/2/3, MEDIUM-1, V18 + base).

Wire format unchanged — purely a tightened acceptance test; backward
compatible at the wire level. Callers update obj.List(off) →
obj.ListStride(off, minStride) per element type.
2026-06-02 15:31:41 -07:00

407 lines
11 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zap
import (
"bytes"
"encoding/binary"
"testing"
)
// buildSeedMessage builds a valid ZAP message using the Builder for use as
// fuzz seed corpus. Returns the raw bytes.
func buildSeedMessage(fields func(ob *ObjectBuilder)) []byte {
b := NewBuilder(256)
ob := b.StartObject(64)
fields(ob)
ob.FinishAsRoot()
return b.Finish()
}
// FuzzParse is a round-trip property fuzzer for Parse:
//
// Properties (all hold for Parse-accepted buffers):
//
// 1. msg.Bytes() == data[:msg.Size()] — Parse holds no derived
// buffer; Bytes() is exactly the declared-size prefix of the input.
// 2. msg.Size() >= HeaderSize && msg.Size() <= len(data)
// — Parse cannot extend past what the caller passed in.
// 3. Parse(msg.Bytes()) succeeds and yields a message with identical
// Bytes() — Parse is idempotent on its own output.
//
// Rejected buffers must return a typed error, never panic. LP-023 Red
// round 3 follow-up #3. Complements FuzzZAPParse (accessor panic-safety on
// arbitrary inputs) by pinning the Parse↔Bytes contract.
func FuzzParse(f *testing.F) {
// Seed corpus: every valid construction we can think of, plus the
// adversarial buffers that exercised RED-HIGH-1/2/3.
f.Add(buildSeedMessage(func(ob *ObjectBuilder) { ob.SetUint64(0, 0xDEADBEEF) }))
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetUint32(0, 42)
ob.SetText(4, "round-trip")
}))
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetBool(0, true)
ob.SetBytes(4, []byte{0xCA, 0xFE, 0xBA, 0xBE})
}))
// Adversarial seeds: corrupted header bytes that should fail Parse.
f.Add([]byte{})
f.Add([]byte{0x5A, 0x41, 0x50, 0x00}) // magic only
{
hdr := make([]byte, HeaderSize)
copy(hdr[0:4], Magic)
binary.LittleEndian.PutUint16(hdr[4:6], 99)
binary.LittleEndian.PutUint32(hdr[12:16], HeaderSize)
f.Add(hdr)
}
// Seed with an empty list (length=0, offset=0).
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetList(0, 0, 0)
ob.SetUint64(8, 1)
}))
// Seed with a length-prefixed list (post-RED-HIGH-1 clamp valid).
{
b := NewBuilder(256)
lb := b.StartList(4)
for i := 0; i < 4; i++ {
lb.AddBytes([]byte{0x00, 0x00, 0x00, byte(i)})
}
listOff, listLen := lb.Finish()
ob := b.StartObject(16)
ob.SetList(0, listOff, listLen)
ob.FinishAsRoot()
f.Add(b.Finish())
}
f.Fuzz(func(t *testing.T, data []byte) {
msg, err := Parse(data)
if err != nil {
// Property: Parse returns a typed error (not a panic) on bad
// inputs. The error itself is opaque to this property — only
// that we got HERE (no panic) matters. Sentinel error values
// are exercised by unit tests, not fuzz.
return
}
// Property 1: Bytes() == data[:Size()]. Parse stores no copy; the
// returned slice MUST alias data[:declaredSize].
size := msg.Size()
if size > len(data) {
t.Fatalf("msg.Size()=%d > len(data)=%d (Parse accepted truncated buffer)", size, len(data))
}
got := msg.Bytes()
if !bytes.Equal(got, data[:size]) {
t.Fatalf("Parse->Bytes mismatch: got len=%d want data[:%d]=len(%d)", len(got), size, size)
}
// Property 2: re-parse of Bytes() succeeds and pins Size() to its
// own length (idempotent — second parse cannot keep shrinking).
msg2, err2 := Parse(got)
if err2 != nil {
t.Fatalf("Parse(Bytes()) failed: %v", err2)
}
if msg2.Size() != size {
t.Fatalf("Parse(Bytes()).Size()=%d != msg.Size()=%d (not idempotent)", msg2.Size(), size)
}
got2 := msg2.Bytes()
if !bytes.Equal(got2, got) {
t.Fatalf("Parse->Bytes->Parse->Bytes drift")
}
// Property 3: Version() returns one of the accepted values
// (Parse-side gate already enforced; this catches regressions).
v := msg.Version()
if v != Version1 && v != Version2 {
t.Fatalf("Parse accepted bad version: %d", v)
}
})
}
// FuzzZAPParse feeds arbitrary bytes to Parse. It must never panic regardless
// of input. Every returned error is acceptable; every non-error result must
// produce a valid Message with accessible root.
func FuzzZAPParse(f *testing.F) {
// Seed 1: valid minimal message (uint64 field)
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetUint64(0, 0xDEADBEEF)
}))
// Seed 2: valid message with text
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetUint32(0, 42)
ob.SetText(4, "hello fuzz")
ob.SetBool(12, true)
}))
// Seed 3: empty slice
f.Add([]byte{})
// Seed 4: too short
f.Add([]byte{0x5A, 0x41, 0x50, 0x00})
// Seed 5: valid header, wrong version
header := make([]byte, HeaderSize)
copy(header[0:4], Magic)
binary.LittleEndian.PutUint16(header[4:6], 99) // bad version
binary.LittleEndian.PutUint32(header[12:16], HeaderSize)
f.Add(header)
// Seed 6: valid header, size exceeds data
header2 := make([]byte, HeaderSize)
copy(header2[0:4], Magic)
binary.LittleEndian.PutUint16(header2[4:6], Version)
binary.LittleEndian.PutUint32(header2[12:16], 9999) // size > len
f.Add(header2)
f.Fuzz(func(t *testing.T, data []byte) {
msg, err := Parse(data)
if err != nil {
return // errors are fine
}
// If parse succeeded, basic accessors must not panic.
_ = msg.Size()
_ = msg.Flags()
_ = msg.Bytes()
root := msg.Root()
_ = root.IsNull()
_ = root.Uint8(0)
_ = root.Uint16(0)
_ = root.Uint32(0)
_ = root.Uint64(0)
_ = root.Int8(0)
_ = root.Int16(0)
_ = root.Int32(0)
_ = root.Int64(0)
_ = root.Float32(0)
_ = root.Float64(0)
_ = root.Bool(0)
_ = root.Text(0)
_ = root.Bytes(0)
// Try reading nested object and list at various offsets
for off := 0; off < 64 && off < msg.Size(); off += 4 {
nested := root.Object(off)
_ = nested.IsNull()
if !nested.IsNull() {
_ = nested.Uint32(0)
}
list := root.List(off)
_ = list.IsNull()
_ = list.Len()
if !list.IsNull() {
_ = list.Uint8(0)
_ = list.Uint32(0)
_ = list.Uint64(0)
_ = list.Bytes()
}
}
})
}
// FuzzZAPRoundtrip builds a ZAP message from fuzzer-supplied field values,
// serializes it, parses it back, and verifies all fields match.
func FuzzZAPRoundtrip(f *testing.F) {
f.Add(uint32(0), uint64(0), int32(0), true, "")
f.Add(uint32(42), uint64(0xDEADBEEF), int32(-100), false, "hello")
f.Add(uint32(0xFFFFFFFF), uint64(0xFFFFFFFFFFFFFFFF), int32(-2147483648), true, "fuzzing is fun")
f.Add(uint32(1), uint64(1), int32(1), false, "a]b\x00c")
f.Fuzz(func(t *testing.T, u32 uint32, u64 uint64, i32 int32, bval bool, text string) {
b := NewBuilder(256)
ob := b.StartObject(32)
ob.SetUint32(0, u32)
ob.SetUint64(8, u64)
ob.SetInt32(16, i32)
ob.SetBool(20, bval)
ob.SetText(24, text) // offset 24, takes 8 bytes (offset+len)
ob.FinishAsRoot()
data := b.Finish()
msg, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed on builder output: %v", err)
}
root := msg.Root()
if got := root.Uint32(0); got != u32 {
t.Errorf("Uint32 roundtrip: got %d, want %d", got, u32)
}
if got := root.Uint64(8); got != u64 {
t.Errorf("Uint64 roundtrip: got %x, want %x", got, u64)
}
if got := root.Int32(16); got != i32 {
t.Errorf("Int32 roundtrip: got %d, want %d", got, i32)
}
if got := root.Bool(20); got != bval {
t.Errorf("Bool roundtrip: got %v, want %v", got, bval)
}
if got := root.Text(24); got != text {
t.Errorf("Text roundtrip: got %q, want %q", got, text)
}
})
}
// FuzzZAPMalformedHeader starts from a valid header and corrupts specific
// bytes. Parse must return an error or a valid message -- never panic.
func FuzzZAPMalformedHeader(f *testing.F) {
// Build a real message to use as the base
base := buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetUint64(0, 12345)
ob.SetUint64(8, 67890)
})
// Seed: corrupt magic byte 0
s1 := make([]byte, len(base))
copy(s1, base)
s1[0] = 0xFF
f.Add(s1)
// Seed: corrupt version field
s2 := make([]byte, len(base))
copy(s2, base)
binary.LittleEndian.PutUint16(s2[4:6], 0xFFFF)
f.Add(s2)
// Seed: zero out size field
s3 := make([]byte, len(base))
copy(s3, base)
binary.LittleEndian.PutUint32(s3[12:16], 0)
f.Add(s3)
// Seed: size = max uint32
s4 := make([]byte, len(base))
copy(s4, base)
binary.LittleEndian.PutUint32(s4[12:16], 0xFFFFFFFF)
f.Add(s4)
// Seed: root offset past end of message
s5 := make([]byte, len(base))
copy(s5, base)
binary.LittleEndian.PutUint32(s5[8:12], 0xFFFFFFFF)
f.Add(s5)
// Seed: valid but truncated
f.Add(base[:HeaderSize])
// Seed: original valid message
f.Add(base)
f.Fuzz(func(t *testing.T, data []byte) {
msg, err := Parse(data)
if err != nil {
// Verify the error is one of the expected sentinel errors
// or at least not a panic.
return
}
// If parse succeeds, ensure basic access is safe
_ = msg.Flags()
_ = msg.Size()
root := msg.Root()
_ = root.Uint64(0)
_ = root.Uint64(8)
_ = root.Text(0)
_ = root.Bytes(0)
_ = root.Object(0)
_ = root.List(0)
})
}
// FuzzZAPLargePayload tests message construction and parsing with payloads
// approaching the 16MB practical limit. The fuzzer controls the payload size
// (capped) and content seed byte. Parse must handle any result gracefully.
func FuzzZAPLargePayload(f *testing.F) {
// Practical max for fuzzing. Real 16MB limit is tested with specific seeds.
const maxFuzzPayload = 1 << 20 // 1MB cap during fuzzing for speed
const limit16MB = 16 * 1024 * 1024
// Seed 1: small payload
f.Add(uint32(64), byte(0xAA))
// Seed 2: medium payload
f.Add(uint32(4096), byte(0x55))
// Seed 3: just under 16MB header-declared size, tiny actual buffer
f.Add(uint32(limit16MB-1), byte(0xFF))
// Seed 4: exactly 16MB
f.Add(uint32(limit16MB), byte(0x00))
// Seed 5: over 16MB
f.Add(uint32(limit16MB+1), byte(0x01))
f.Fuzz(func(t *testing.T, requestedSize uint32, fill byte) {
// Cap actual allocation to avoid OOM in fuzzing
actualSize := int(requestedSize)
if actualSize > maxFuzzPayload {
actualSize = maxFuzzPayload
}
if actualSize < HeaderSize {
actualSize = HeaderSize
}
// Build a raw buffer with valid header but large payload
buf := make([]byte, actualSize)
copy(buf[0:4], Magic)
binary.LittleEndian.PutUint16(buf[4:6], Version)
binary.LittleEndian.PutUint32(buf[8:12], uint32(HeaderSize)) // root at header
binary.LittleEndian.PutUint32(buf[12:16], uint32(actualSize))
// Fill data segment with the fuzz byte
for i := HeaderSize; i < actualSize; i++ {
buf[i] = fill
}
msg, err := Parse(buf)
if err != nil {
return
}
// Basic access must not panic
if msg.Size() != actualSize {
t.Errorf("Size mismatch: got %d, want %d", msg.Size(), actualSize)
}
root := msg.Root()
_ = root.Uint8(0)
_ = root.Uint64(0)
_ = root.Text(0)
_ = root.Bytes(0)
_ = root.Object(0)
_ = root.List(0)
// Also test via Builder for sizes that fit
if actualSize <= 1<<18 { // 256KB via builder
payloadSize := actualSize - HeaderSize
if payloadSize < 0 {
payloadSize = 0
}
payload := bytes.Repeat([]byte{fill}, payloadSize)
b := NewBuilder(actualSize + 64)
lb := b.StartList(1)
lb.AddBytes(payload)
listOff, listLen := lb.Finish()
ob := b.StartObject(16)
ob.SetList(0, listOff, listLen)
ob.SetUint32(8, requestedSize)
ob.FinishAsRoot()
data := b.Finish()
msg2, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed on builder-constructed large message: %v", err)
}
list := msg2.Root().List(0)
gotBytes := list.Bytes()
if len(gotBytes) != payloadSize {
t.Errorf("List.Bytes() len = %d, want %d", len(gotBytes), payloadSize)
}
}
})
}