mirror of
https://github.com/zap-proto/go.git
synced 2026-10-30 15:05:00 +00:00
An out-of-range read answers zero rather than faulting; that is what lets a hostile buffer reach a typed accessor with no validation pass in front of it. It did not hold at the one place a truncated or hostile buffer steers a reader to: a null pointer field resolves to the zero Object, whose accessors then dereferenced a nil *Message. A generated nested-struct accessor returns that view by value, so a caller had no way to test for it before reading — the panic was reachable from ordinary generated code. Object.data() answers nil for the zero view, and every Object accessor reads through it. The C++ runtime already behaved this way; the two now agree.
450 lines
14 KiB
Go
450 lines
14 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package zap implements the Zero-copy Application Protocol (ZAP) runtime.
|
|
//
|
|
// ZAP is a binary serialization format designed for high-performance
|
|
// inter-process and network communication. It uses a fixed-offset layout
|
|
// so reads are zero-copy: every field is accessed directly from the
|
|
// underlying byte buffer at a compile-time offset, with no parse pass and
|
|
// no allocation.
|
|
//
|
|
// This package provides the canonical Go runtime for the ZAP wire format.
|
|
// The specification lives at github.com/zap-proto/zap-spec; code generation
|
|
// from ZAP schemas is handled by github.com/zap-proto/go/cmd/zapgen (not
|
|
// yet released in this skeleton).
|
|
//
|
|
// Wire Format:
|
|
//
|
|
// ┌─────────────────────────────────────────────────┐
|
|
// │ Header (16 bytes) │
|
|
// │ ├─ Magic (4 bytes): "ZAP\x00" │
|
|
// │ ├─ Version (2 bytes): 1 or 2 │
|
|
// │ ├─ Flags (2 bytes): compression, etc. │
|
|
// │ ├─ Root Offset (4 bytes): offset to root │
|
|
// │ └─ Size (4 bytes): total message size │
|
|
// ├─────────────────────────────────────────────────┤
|
|
// │ Data Segment (variable) │
|
|
// │ └─ Structs, lists, text, bytes... │
|
|
// └─────────────────────────────────────────────────┘
|
|
//
|
|
// All multi-byte integers are little-endian. Offsets are relative to
|
|
// the position of the offset field itself.
|
|
package zap
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"math"
|
|
"unsafe"
|
|
)
|
|
|
|
const (
|
|
// HeaderSize is the size of the ZAP message header
|
|
HeaderSize = 16
|
|
|
|
// Magic bytes identifying a ZAP message
|
|
Magic = "ZAP\x00"
|
|
|
|
// ZAP wire versions. Two schema generations share the same data-segment
|
|
// encoding and differ only in the meaning of the leading struct byte:
|
|
//
|
|
// Version1 — original layout (e.g. legacy platformvm v2 schema, where
|
|
// byte 0 of the root struct is a payload field).
|
|
// Version2 — adds a one-byte discriminator at struct byte 0 (the v3
|
|
// platformvm TxKind tag); every later field shifts by +1.
|
|
//
|
|
// The reader ACCEPTS both versions (forward-compatible parse); consumers
|
|
// that require a specific schema generation gate on Message.Version after
|
|
// Parse. The data segment past the 6-byte magic+version prefix is byte-
|
|
// identical regardless of version — the version byte is the only header
|
|
// difference between a v1 and a v2 buffer carrying the same payload.
|
|
//
|
|
// Version (the bare constant) is the version this runtime's Builder emits
|
|
// by default. zap-proto/go is the pure-stdlib baseline runtime and emits
|
|
// Version1; the hardened downstream runtime (github.com/luxfi/zap) emits
|
|
// Version2 by default. Both readers accept both — see the cross-wire
|
|
// conformance test (zap_crosswire_test.go).
|
|
Version1 uint16 = 1
|
|
Version2 uint16 = 2
|
|
Version uint16 = Version1
|
|
|
|
// DefaultPort is the canonical TCP port for ZAP transport. Like 80
|
|
// means HTTP and 443 means HTTPS, 9999 means ZAP — services that
|
|
// host ZAP endpoints bind this port by convention; the DNS name
|
|
// disambiguates which service is on the other end.
|
|
DefaultPort = 9999
|
|
|
|
// Alignment for data segments
|
|
Alignment = 8
|
|
)
|
|
|
|
// Flags for message header
|
|
const (
|
|
FlagNone uint16 = 0
|
|
FlagCompressed uint16 = 1 << 0
|
|
FlagEncrypted uint16 = 1 << 1
|
|
FlagSigned uint16 = 1 << 2
|
|
)
|
|
|
|
var (
|
|
ErrInvalidMagic = errors.New("zap: invalid magic bytes")
|
|
ErrInvalidVersion = errors.New("zap: unsupported version")
|
|
ErrBufferTooSmall = errors.New("zap: buffer too small")
|
|
ErrOutOfBounds = errors.New("zap: offset out of bounds")
|
|
ErrInvalidOffset = errors.New("zap: invalid offset")
|
|
)
|
|
|
|
// Message is a ZAP message that can be read zero-copy.
|
|
type Message struct {
|
|
data []byte
|
|
}
|
|
|
|
// Parse parses a ZAP message from bytes without copying.
|
|
//
|
|
// Accepts both Version1 and Version2 wire headers (forward-compatible read).
|
|
// Callers that require a specific schema generation gate on Message.Version
|
|
// after Parse.
|
|
//
|
|
// The declared size field must be at least HeaderSize: a size=0 buffer would
|
|
// otherwise pass Parse and then panic on subsequent Root()/Flags() reads
|
|
// against the empty slice. It is rejected at the wire boundary.
|
|
func Parse(data []byte) (*Message, error) {
|
|
if len(data) < HeaderSize {
|
|
return nil, ErrBufferTooSmall
|
|
}
|
|
|
|
// Check magic
|
|
if string(data[0:4]) != Magic {
|
|
return nil, ErrInvalidMagic
|
|
}
|
|
|
|
// Check version (accept v1 + v2; reject anything else).
|
|
version := binary.LittleEndian.Uint16(data[4:6])
|
|
if version != Version1 && version != Version2 {
|
|
return nil, ErrInvalidVersion
|
|
}
|
|
|
|
// Validate size: must be at least the header (else Root()/Flags() would
|
|
// panic on data[:size]) and at most the input length.
|
|
size := binary.LittleEndian.Uint32(data[12:16])
|
|
if int(size) < HeaderSize || int(size) > len(data) {
|
|
return nil, ErrBufferTooSmall
|
|
}
|
|
|
|
return &Message{data: data[:size]}, nil
|
|
}
|
|
|
|
// Version returns the wire version of the message (Version1 or Version2).
|
|
func (m *Message) Version() uint16 {
|
|
return binary.LittleEndian.Uint16(m.data[4:6])
|
|
}
|
|
|
|
// Bytes returns the underlying byte slice.
|
|
func (m *Message) Bytes() []byte {
|
|
return m.data
|
|
}
|
|
|
|
// Size returns the total message size.
|
|
func (m *Message) Size() int {
|
|
return len(m.data)
|
|
}
|
|
|
|
// Flags returns the message flags.
|
|
func (m *Message) Flags() uint16 {
|
|
return binary.LittleEndian.Uint16(m.data[6:8])
|
|
}
|
|
|
|
// Root returns the root object of the message.
|
|
func (m *Message) Root() Object {
|
|
offset := binary.LittleEndian.Uint32(m.data[8:12])
|
|
return Object{msg: m, offset: int(offset)}
|
|
}
|
|
|
|
// Object is a zero-copy view into a ZAP struct.
|
|
type Object struct {
|
|
msg *Message
|
|
offset int
|
|
}
|
|
|
|
// IsNull returns true if the object is null.
|
|
func (o Object) IsNull() bool {
|
|
return o.offset == 0
|
|
}
|
|
|
|
// data returns the bytes this view reads, or nil for the zero Object.
|
|
//
|
|
// A null pointer field yields the zero Object — Object() and List() answer it
|
|
// for a zero or out-of-range relative offset — and reading a field of it must
|
|
// answer zero, not fault. Without this the whole read side is total except at
|
|
// exactly the place a hostile or truncated buffer steers a reader to: the
|
|
// pointer that resolves to nothing. Generated accessors return the zero view
|
|
// by value, so a caller has no way to test for it before reading.
|
|
func (o Object) data() []byte {
|
|
if o.msg == nil {
|
|
return nil
|
|
}
|
|
return o.msg.data
|
|
}
|
|
|
|
// Bool reads a bool at the given field offset.
|
|
func (o Object) Bool(fieldOffset int) bool {
|
|
return o.Uint8(fieldOffset) != 0
|
|
}
|
|
|
|
// Uint8 reads a uint8 at the given field offset.
|
|
func (o Object) Uint8(fieldOffset int) uint8 {
|
|
pos := o.offset + fieldOffset
|
|
if pos >= len(o.data()) {
|
|
return 0
|
|
}
|
|
return o.data()[pos]
|
|
}
|
|
|
|
// Uint16 reads a uint16 at the given field offset.
|
|
func (o Object) Uint16(fieldOffset int) uint16 {
|
|
pos := o.offset + fieldOffset
|
|
if pos+2 > len(o.data()) {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint16(o.data()[pos:])
|
|
}
|
|
|
|
// Uint32 reads a uint32 at the given field offset.
|
|
func (o Object) Uint32(fieldOffset int) uint32 {
|
|
pos := o.offset + fieldOffset
|
|
if pos+4 > len(o.data()) {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint32(o.data()[pos:])
|
|
}
|
|
|
|
// Uint64 reads a uint64 at the given field offset.
|
|
func (o Object) Uint64(fieldOffset int) uint64 {
|
|
pos := o.offset + fieldOffset
|
|
if pos+8 > len(o.data()) {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint64(o.data()[pos:])
|
|
}
|
|
|
|
// Int8 reads an int8 at the given field offset.
|
|
func (o Object) Int8(fieldOffset int) int8 {
|
|
return int8(o.Uint8(fieldOffset))
|
|
}
|
|
|
|
// Int16 reads an int16 at the given field offset.
|
|
func (o Object) Int16(fieldOffset int) int16 {
|
|
return int16(o.Uint16(fieldOffset))
|
|
}
|
|
|
|
// Int32 reads an int32 at the given field offset.
|
|
func (o Object) Int32(fieldOffset int) int32 {
|
|
return int32(o.Uint32(fieldOffset))
|
|
}
|
|
|
|
// Int64 reads an int64 at the given field offset.
|
|
func (o Object) Int64(fieldOffset int) int64 {
|
|
return int64(o.Uint64(fieldOffset))
|
|
}
|
|
|
|
// Float32 reads a float32 at the given field offset.
|
|
func (o Object) Float32(fieldOffset int) float32 {
|
|
return math.Float32frombits(o.Uint32(fieldOffset))
|
|
}
|
|
|
|
// Float64 reads a float64 at the given field offset.
|
|
func (o Object) Float64(fieldOffset int) float64 {
|
|
return math.Float64frombits(o.Uint64(fieldOffset))
|
|
}
|
|
|
|
// Text reads a string at the given field offset (zero-copy).
|
|
func (o Object) Text(fieldOffset int) string {
|
|
b := o.Bytes(fieldOffset)
|
|
if len(b) == 0 {
|
|
return ""
|
|
}
|
|
// Zero-copy string conversion
|
|
return unsafe.String(&b[0], len(b))
|
|
}
|
|
|
|
// Bytes reads a byte slice at the given field offset (zero-copy).
|
|
//
|
|
// Wire-format rule: relOffset is an UNSIGNED forward pointer from the field
|
|
// position into the variable section. Negative bit-patterns (high bit set)
|
|
// flow through uint32→int conversion as large positive values and are
|
|
// rejected by the absPos+length > len(data) bounds check. This closes the
|
|
// pointer-escape malleability surface where a signed cast would let a crafted
|
|
// relOffset alias bytes back inside the fixed section or the wire header — a
|
|
// Bytes target can never legitimately live in offsets 0..HeaderSize-1.
|
|
func (o Object) Bytes(fieldOffset int) []byte {
|
|
pos := o.offset + fieldOffset
|
|
if pos+4 > len(o.data()) {
|
|
return nil
|
|
}
|
|
|
|
// Read offset (relative, unsigned forward pointer) and length.
|
|
relOffset := binary.LittleEndian.Uint32(o.data()[pos:])
|
|
if relOffset == 0 {
|
|
return nil // Null
|
|
}
|
|
|
|
lenPos := pos + 4
|
|
if lenPos+4 > len(o.data()) {
|
|
return nil
|
|
}
|
|
length := binary.LittleEndian.Uint32(o.data()[lenPos:])
|
|
|
|
// Calculate absolute position. Reject any payload that lands inside the
|
|
// wire header — Bytes targets cannot live in offsets 0..HeaderSize-1.
|
|
absPos := pos + int(relOffset)
|
|
if absPos < HeaderSize {
|
|
return nil
|
|
}
|
|
if absPos+int(length) > len(o.data()) {
|
|
return nil
|
|
}
|
|
|
|
return o.data()[absPos : absPos+int(length)]
|
|
}
|
|
|
|
// Object reads a nested object at the given field offset.
|
|
//
|
|
// Wire-format rule: relOffset is SIGNED. The builder may finalize a nested
|
|
// object BEFORE its parent (in which case the nested payload lives EARLIER in
|
|
// the variable section than the parent's pointer cell, and the relOffset is
|
|
// negative). The bounds check rejects any absOffset outside the message; for
|
|
// the Bytes-malleability rule see Bytes().
|
|
//
|
|
// An attacker can use a backward relOffset to alias the WIRE HEADER (offsets
|
|
// 0..HeaderSize-1). The header carries Magic/Version/Flags/RootOffset/Size —
|
|
// none of which is a legitimate object payload. Any absOffset < HeaderSize is
|
|
// rejected. The signed cast still lets honest builders point backward to
|
|
// nested objects they finalized first (which live at offset >= HeaderSize).
|
|
func (o Object) Object(fieldOffset int) Object {
|
|
pos := o.offset + fieldOffset
|
|
if pos+4 > len(o.data()) {
|
|
return Object{}
|
|
}
|
|
|
|
relOffset := int32(binary.LittleEndian.Uint32(o.data()[pos:]))
|
|
if relOffset == 0 {
|
|
return Object{} // Null
|
|
}
|
|
|
|
absOffset := pos + int(relOffset)
|
|
if absOffset < HeaderSize || absOffset >= len(o.data()) {
|
|
return Object{}
|
|
}
|
|
|
|
return Object{msg: o.msg, offset: absOffset}
|
|
}
|
|
|
|
// List reads a list at the given field offset.
|
|
//
|
|
// Wire-format rule: relOffset is SIGNED (see Object()). Any absOffset <
|
|
// HeaderSize is rejected (lists cannot start inside the wire header). The
|
|
// length field is bounded by the total message size — an attacker-set
|
|
// length=0xFFFFFFFF would otherwise let a downstream `for i := 0; i < Len()`
|
|
// loop iterate 4G times even though every per-element accessor returns 0.
|
|
func (o Object) List(fieldOffset int) List {
|
|
pos := o.offset + fieldOffset
|
|
if pos+8 > len(o.data()) {
|
|
return List{}
|
|
}
|
|
|
|
relOffset := int32(binary.LittleEndian.Uint32(o.data()[pos:]))
|
|
if relOffset == 0 {
|
|
return List{} // Null
|
|
}
|
|
|
|
length := binary.LittleEndian.Uint32(o.data()[pos+4:])
|
|
|
|
// Clamp length to the message size. The tightest bound is
|
|
// length*minElementSize, but element size is per-list-accessor (Uint8 is
|
|
// 1B, Uint32 is 4B, struct lists carry their own stride). The wire layer
|
|
// cannot know the stride, so use the permissive `length <= len(data)`
|
|
// baseline — every per-element accessor re-checks its own bounds. This
|
|
// rejects the 0xFFFFFFFF DoS without false-rejecting honest 1-byte-stride
|
|
// lists that span the entire message.
|
|
if int(length) > len(o.data()) {
|
|
return List{}
|
|
}
|
|
|
|
absOffset := pos + int(relOffset)
|
|
if absOffset < HeaderSize || absOffset >= len(o.data()) {
|
|
return List{}
|
|
}
|
|
|
|
return List{msg: o.msg, offset: absOffset, length: int(length)}
|
|
}
|
|
|
|
// List is a zero-copy view into a ZAP list.
|
|
type List struct {
|
|
msg *Message
|
|
offset int
|
|
length int
|
|
}
|
|
|
|
// Len returns the number of elements.
|
|
func (l List) Len() int {
|
|
return l.length
|
|
}
|
|
|
|
// IsNull returns true if the list is null.
|
|
func (l List) IsNull() bool {
|
|
return l.msg == nil
|
|
}
|
|
|
|
// Uint8 returns a uint8 list element.
|
|
func (l List) Uint8(i int) uint8 {
|
|
if i < 0 || i >= l.length {
|
|
return 0
|
|
}
|
|
pos := l.offset + i
|
|
if pos >= len(l.msg.data) {
|
|
return 0
|
|
}
|
|
return l.msg.data[pos]
|
|
}
|
|
|
|
// Uint32 returns a uint32 list element.
|
|
func (l List) Uint32(i int) uint32 {
|
|
if i < 0 || i >= l.length {
|
|
return 0
|
|
}
|
|
pos := l.offset + i*4
|
|
if pos+4 > len(l.msg.data) {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint32(l.msg.data[pos:])
|
|
}
|
|
|
|
// Uint64 returns a uint64 list element.
|
|
func (l List) Uint64(i int) uint64 {
|
|
if i < 0 || i >= l.length {
|
|
return 0
|
|
}
|
|
pos := l.offset + i*8
|
|
if pos+8 > len(l.msg.data) {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint64(l.msg.data[pos:])
|
|
}
|
|
|
|
// Object returns an object list element.
|
|
func (l List) Object(i int, elemSize int) Object {
|
|
if i < 0 || i >= l.length {
|
|
return Object{}
|
|
}
|
|
return Object{msg: l.msg, offset: l.offset + i*elemSize}
|
|
}
|
|
|
|
// Bytes returns the raw bytes of the list (for byte lists).
|
|
func (l List) Bytes() []byte {
|
|
if l.msg == nil || l.offset+l.length > len(l.msg.data) {
|
|
return nil
|
|
}
|
|
return l.msg.data[l.offset : l.offset+l.length]
|
|
}
|