diff --git a/internal/sandboxwire/codec.go b/internal/sandboxwire/codec.go new file mode 100644 index 000000000..da12a9dff --- /dev/null +++ b/internal/sandboxwire/codec.go @@ -0,0 +1,179 @@ +package sandboxwire + +import ( + "encoding/binary" + "fmt" +) + +// Encoder appends primitives to a payload. The zero value is ready to use. +type Encoder struct { + b []byte +} + +// Payload returns the encoded bytes. +func (e *Encoder) Payload() []byte { return e.b } + +func (e *Encoder) U8(v uint8) { e.b = append(e.b, v) } +func (e *Encoder) U16(v uint16) { e.b = binary.BigEndian.AppendUint16(e.b, v) } +func (e *Encoder) U32(v uint32) { e.b = binary.BigEndian.AppendUint32(e.b, v) } +func (e *Encoder) U64(v uint64) { e.b = binary.BigEndian.AppendUint64(e.b, v) } +func (e *Encoder) I64(v int64) { e.U64(uint64(v)) } + +// Bool writes 1 for true and 0 for false. +func (e *Encoder) Bool(v bool) { + if v { + e.U8(1) + } else { + e.U8(0) + } +} + +// Bytes writes a uint32 length followed by v. +func (e *Encoder) Bytes(v []byte) { + e.U32(uint32(len(v))) + e.b = append(e.b, v...) +} + +// Count writes the uint32 element count of an array. +func (e *Encoder) Count(n int) { e.U32(uint32(n)) } + +// Present writes the presence byte of an optional field; the value follows only +// when ok. +func (e *Encoder) Present(ok bool) { e.Bool(ok) } + +// Enum writes a uint16 enum value. Zero is invalid on the wire. +func (e *Encoder) Enum(v uint16) { e.U16(v) } + +func (e *Encoder) ID(id ID) { e.b = append(e.b, id[:]...) } + +func (e *Encoder) Effect(v Effect) { e.Enum(uint16(v)) } + +// Decoder reads primitives from a payload. Every error wraps ErrMalformed; +// after an error the Decoder's position is unspecified. +type Decoder struct { + b []byte +} + +func NewDecoder(payload []byte) *Decoder { return &Decoder{b: payload} } + +// Remaining returns the number of unread bytes. +func (d *Decoder) Remaining() int { return len(d.b) } + +// Finish fails when bytes remain unread. +func (d *Decoder) Finish() error { + if len(d.b) != 0 { + return fmt.Errorf("%w: %d trailing bytes", ErrMalformed, len(d.b)) + } + return nil +} + +func (d *Decoder) take(n uint64, what string) ([]byte, error) { + if n > uint64(len(d.b)) { + return nil, fmt.Errorf("%w: %s needs %d bytes, %d remain", ErrMalformed, what, n, len(d.b)) + } + v := d.b[:n:n] + d.b = d.b[n:] + return v, nil +} + +func (d *Decoder) U8() (uint8, error) { + b, err := d.take(1, "u8") + if err != nil { + return 0, err + } + return b[0], nil +} + +func (d *Decoder) U16() (uint16, error) { + b, err := d.take(2, "u16") + if err != nil { + return 0, err + } + return binary.BigEndian.Uint16(b), nil +} + +func (d *Decoder) U32() (uint32, error) { + b, err := d.take(4, "u32") + if err != nil { + return 0, err + } + return binary.BigEndian.Uint32(b), nil +} + +func (d *Decoder) U64() (uint64, error) { + b, err := d.take(8, "u64") + if err != nil { + return 0, err + } + return binary.BigEndian.Uint64(b), nil +} + +func (d *Decoder) I64() (int64, error) { + v, err := d.U64() + return int64(v), err +} + +// Bool accepts exactly 0 or 1. +func (d *Decoder) Bool() (bool, error) { + v, err := d.U8() + if err == nil && v > 1 { + err = fmt.Errorf("%w: boolean byte %d", ErrMalformed, v) + } + return v == 1, err +} + +// Bytes reads a uint32 length and that many bytes. The result aliases the +// payload, with its capacity clipped to its length. +func (d *Decoder) Bytes() ([]byte, error) { + n, err := d.U32() + if err != nil { + return nil, err + } + return d.take(uint64(n), "byte string") +} + +// Count reads an array's uint32 element count. The count is at most max and at +// most the remaining bytes, because every element encodes to at least one byte, +// so a caller may allocate count elements. +func (d *Decoder) Count(max uint32) (int, error) { + n, err := d.U32() + switch { + case err != nil: + return 0, err + case n > max: + return 0, fmt.Errorf("%w: count %d exceeds maximum %d", ErrMalformed, n, max) + case uint64(n) > uint64(len(d.b)): + return 0, fmt.Errorf("%w: count %d exceeds %d remaining bytes", ErrMalformed, n, len(d.b)) + } + return int(n), nil +} + +// Present reads the presence byte of an optional field. +func (d *Decoder) Present() (bool, error) { return d.Bool() } + +// Enum reads a uint16 enum value, rejecting zero and values valid does not +// accept. +func (d *Decoder) Enum(valid func(uint16) bool) (uint16, error) { + v, err := d.U16() + if err == nil && (v == 0 || !valid(v)) { + err = fmt.Errorf("%w: enum value %d", ErrMalformed, v) + } + return v, err +} + +// ID reads a required identifier, rejecting the zero ID. +func (d *Decoder) ID() (ID, error) { + b, err := d.take(uint64(len(ID{})), "identifier") + if err != nil { + return ID{}, err + } + if id := ID(b); !id.IsZero() { + return id, nil + } + return ID{}, fmt.Errorf("%w: zero identifier", ErrMalformed) +} + +func (d *Decoder) Effect() (Effect, error) { + v, err := d.Enum(func(v uint16) bool { return Effect(v).Valid() }) + return Effect(v), err +} diff --git a/internal/sandboxwire/frame.go b/internal/sandboxwire/frame.go new file mode 100644 index 000000000..bd4d53737 --- /dev/null +++ b/internal/sandboxwire/frame.go @@ -0,0 +1,134 @@ +// Package sandboxwire holds the frame header and the primitive encoding shared +// by the File, Process and Link protocols, plus the few values more than one of +// them uses. +// +// It defines no messages. Each protocol's protocol.go owns its message tags, +// ordered payload layouts, validators and encoding. On the wire an enum is a +// uint16 whose zero value is invalid, and an optional field is a presence byte +// followed by the value. +package sandboxwire + +import ( + "encoding/binary" + "errors" + "fmt" + "io" +) + +const ( + // HeaderSize is the length of the frame header: PayloadLength uint32, + // MessageType uint16, Flags uint16 and RequestID uint64, in network byte + // order. + HeaderSize = 16 + // MaxPayload is the hard maximum frame payload. Advertised limits may be + // smaller. + MaxPayload = 1 << 20 + // MaxChunk is the maximum file or process data chunk. + MaxChunk = 64 << 10 +) + +// ErrMalformed is the single error for bytes that violate the wire rules. +// Errors from this package and from protocol decoders wrap it. +var ErrMalformed = errors.New("sandboxwire: malformed message") + +// Frame is one decoded frame. RequestID is zero only for unsolicited events. +type Frame struct { + Type uint16 + RequestID uint64 + Payload []byte +} + +// ReadFrame reads one frame. It rejects a payload length above maxPayload +// (capped at MaxPayload) before allocating, and rejects nonzero flags. It +// returns io.EOF only when r ends before the first header byte. +func ReadFrame(r io.Reader, maxPayload uint32) (Frame, error) { + var h [HeaderSize]byte + if _, err := io.ReadFull(r, h[:]); err != nil { + return Frame{}, err + } + n, limit := binary.BigEndian.Uint32(h[0:4]), min(maxPayload, MaxPayload) + if n > limit { + return Frame{}, fmt.Errorf("%w: payload length %d exceeds limit %d", ErrMalformed, n, limit) + } + if flags := binary.BigEndian.Uint16(h[6:8]); flags != 0 { + return Frame{}, fmt.Errorf("%w: frame flags %#04x", ErrMalformed, flags) + } + f := Frame{ + Type: binary.BigEndian.Uint16(h[4:6]), + RequestID: binary.BigEndian.Uint64(h[8:16]), + Payload: make([]byte, n), + } + if _, err := io.ReadFull(r, f.Payload); err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return Frame{}, err + } + return f, nil +} + +// WriteFrame writes f with a single Write call, so frames written by goroutines +// sharing w under a mutex never interleave. +func WriteFrame(w io.Writer, f Frame) error { + if len(f.Payload) > MaxPayload { + return fmt.Errorf("%w: payload length %d exceeds limit %d", ErrMalformed, len(f.Payload), MaxPayload) + } + b := make([]byte, HeaderSize+len(f.Payload)) + binary.BigEndian.PutUint32(b[0:4], uint32(len(f.Payload))) + binary.BigEndian.PutUint16(b[4:6], f.Type) + binary.BigEndian.PutUint64(b[8:16], f.RequestID) + copy(b[HeaderSize:], f.Payload) + _, err := w.Write(b) + return err +} + +const responseBit = 0x8000 + +// FirstEvent is the first event tag. A protocol numbers its events upward from +// it in the order it lists them. +const FirstEvent = 0x4001 + +// ResponseType returns the tag of the response to request tag req. +func ResponseType(req uint16) uint16 { return req | responseBit } + +// IsResponse reports whether t is a response tag. +func IsResponse(t uint16) bool { return t&responseBit != 0 } + +// ValidRequestID reports whether id may identify a request. Zero is reserved +// for unsolicited events. +func ValidRequestID(id uint64) bool { return id != 0 } + +// Kind is the role of a message type. +type Kind uint8 + +const ( + KindRequest Kind = iota + 1 + KindResponse + KindEvent +) + +// Tags is a protocol's known message types: requests 1 through Requests, their +// responses, and events FirstEvent through FirstEvent+Events-1. Requests stays +// below FirstEvent. +type Tags struct { + Requests uint16 + Events uint16 +} + +// Classify returns the kind of message type t, or ErrMalformed when t is not +// one of the known tags. +func (k Tags) Classify(t uint16) (Kind, error) { + switch { + case IsResponse(t): + if req := t &^ responseBit; req >= 1 && req <= k.Requests { + return KindResponse, nil + } + case t >= FirstEvent: + if t-FirstEvent < k.Events { + return KindEvent, nil + } + case t >= 1 && t <= k.Requests: + return KindRequest, nil + } + return 0, fmt.Errorf("%w: unknown message type %#04x", ErrMalformed, t) +} diff --git a/internal/sandboxwire/sandboxwire_test.go b/internal/sandboxwire/sandboxwire_test.go new file mode 100644 index 000000000..4a09b39e9 --- /dev/null +++ b/internal/sandboxwire/sandboxwire_test.go @@ -0,0 +1,233 @@ +package sandboxwire + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "io" + "os" + "reflect" + "strings" + "testing" +) + +// sample exercises every primitive in the order the golden fixture lists them. +type sample struct { + A uint8 + B uint16 + C uint32 + D uint64 + E int64 + F bool + Kind uint16 + Effect Effect + Ref *ID + Items [][]byte +} + +const sampleMaxItems = 8 + +func sampleKind(v uint16) bool { return v <= 3 } + +func (s sample) encode(e *Encoder) { + e.U8(s.A) + e.U16(s.B) + e.U32(s.C) + e.U64(s.D) + e.I64(s.E) + e.Bool(s.F) + e.Enum(s.Kind) + e.Effect(s.Effect) + e.Present(s.Ref != nil) + if s.Ref != nil { + e.ID(*s.Ref) + } + e.Count(len(s.Items)) + for _, item := range s.Items { + e.Bytes(item) + } +} + +// get runs read unless an earlier read failed, keeping the first error. +func get[T any](err *error, read func() (T, error)) T { + var v T + if *err == nil { + v, *err = read() + } + return v +} + +func decodeSample(t *testing.T, p []byte) (sample, error) { + var s sample + var err error + d := NewDecoder(p) + s.A = get(&err, d.U8) + s.B = get(&err, d.U16) + s.C = get(&err, d.U32) + s.D = get(&err, d.U64) + s.E = get(&err, d.I64) + s.F = get(&err, d.Bool) + s.Kind = get(&err, func() (uint16, error) { return d.Enum(sampleKind) }) + s.Effect = get(&err, d.Effect) + if get(&err, d.Present) { + id := get(&err, d.ID) + s.Ref = &id + } + remaining := d.Remaining() + n := get(&err, func() (int, error) { return d.Count(sampleMaxItems) }) + if n > sampleMaxItems || n > remaining { + t.Fatalf("count %d escapes its bounds (max %d, %d bytes remained)", n, sampleMaxItems, remaining) + } + s.Items = make([][]byte, n) + for i := range s.Items { + s.Items[i] = get(&err, d.Bytes) + if cap(s.Items[i]) != len(s.Items[i]) { + t.Fatalf("byte string capacity %d exceeds its length %d", cap(s.Items[i]), len(s.Items[i])) + } + } + if err != nil { + return s, err + } + return s, d.Finish() +} + +func readHexFixture(t testing.TB, name string) []byte { + t.Helper() + raw, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + var digits strings.Builder + for _, line := range strings.Split(string(raw), "\n") { + line, _, _ = strings.Cut(line, "#") + digits.WriteString(strings.Join(strings.Fields(line), "")) + } + b, err := hex.DecodeString(digits.String()) + if err != nil { + t.Fatal(err) + } + return b +} + +type countingWriter struct { + bytes.Buffer + writes int +} + +func (w *countingWriter) Write(p []byte) (int, error) { + w.writes++ + return w.Buffer.Write(p) +} + +func TestGoldenFrame(t *testing.T) { + want := readHexFixture(t, "testdata/frame_v1.hex") + ref := ID{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f} + msg := sample{A: 7, B: 0x0102, C: 0x03040506, D: 0x0708090a0b0c0d0e, E: -2, F: true, Kind: 2, Effect: EffectPossible, Ref: &ref, Items: [][]byte{[]byte("ab"), {}}} + + var e Encoder + msg.encode(&e) + var w countingWriter + if err := WriteFrame(&w, Frame{Type: ResponseType(3), RequestID: 0x0102030405060708, Payload: e.Payload()}); err != nil { + t.Fatal(err) + } + if !bytes.Equal(w.Bytes(), want) || w.writes != 1 { + t.Fatalf("encoded frame in %d writes:\n got %x\nwant %x", w.writes, w.Bytes(), want) + } + + f, err := ReadFrame(bytes.NewReader(want), MaxPayload) + if err != nil { + t.Fatal(err) + } + got, err := decodeSample(t, f.Payload) + if err != nil { + t.Fatal(err) + } + if f.Type != 0x8003 || f.RequestID != 0x0102030405060708 || !reflect.DeepEqual(got, msg) { + t.Fatalf("decoded %#x %#x %+v, want %+v", f.Type, f.RequestID, got, msg) + } +} + +func header(length uint32, flags uint16) []byte { + h := binary.BigEndian.AppendUint32(nil, length) + h = binary.BigEndian.AppendUint16(h, 1) + h = binary.BigEndian.AppendUint16(h, flags) + return binary.BigEndian.AppendUint64(h, 1) +} + +func errOf[T any](_ T, err error) error { return err } + +func TestRejectsMalformed(t *testing.T) { + anyEnum := func(uint16) bool { return true } + tags := Tags{Requests: 2, Events: 1} + for _, tc := range []struct { + name string + err error + }{ + // Each reader holds only a header, so these fail before any payload read. + {"length above MaxPayload", errOf(ReadFrame(bytes.NewReader(header(MaxPayload+1, 0)), ^uint32(0)))}, + {"length above advertised limit", errOf(ReadFrame(bytes.NewReader(header(65, 0)), 64))}, + {"nonzero flags", errOf(ReadFrame(bytes.NewReader(header(0, 1)), MaxPayload))}, + {"oversize write", WriteFrame(io.Discard, Frame{Type: 1, RequestID: 1, Payload: make([]byte, MaxPayload+1)})}, + {"trailing bytes", NewDecoder([]byte{0}).Finish()}, + {"bool 2", errOf(NewDecoder([]byte{2}).Bool())}, + {"zero enum", errOf(NewDecoder([]byte{0, 0}).Enum(anyEnum))}, + {"unknown enum", errOf(NewDecoder([]byte{0, 4}).Enum(sampleKind))}, + {"zero ID", errOf(NewDecoder(make([]byte, 16)).ID())}, + {"count above remaining bytes", errOf(NewDecoder([]byte{0, 0, 0, 2, 0}).Count(10))}, + {"count above maximum", errOf(NewDecoder([]byte{0, 0, 0, 2, 0, 0}).Count(1))}, + {"byte string above remaining bytes", errOf(NewDecoder([]byte{0, 0, 0, 2, 0}).Bytes())}, + {"truncated integer", errOf(NewDecoder([]byte{0, 0, 0}).U32())}, + {"unknown request", errOf(tags.Classify(3))}, + {"unknown response", errOf(tags.Classify(ResponseType(3)))}, + {"unknown event", errOf(tags.Classify(FirstEvent + 1))}, + {"type zero", errOf(tags.Classify(0))}, + } { + if !errors.Is(tc.err, ErrMalformed) { + t.Errorf("%s: got %v, want ErrMalformed", tc.name, tc.err) + } + } +} + +func TestClassify(t *testing.T) { + tags := Tags{Requests: 2, Events: 1} + for typ, want := range map[uint16]Kind{2: KindRequest, ResponseType(2): KindResponse, FirstEvent: KindEvent} { + if got, err := tags.Classify(typ); got != want || err != nil { + t.Errorf("Classify(%#x) = %v, %v; want %v", typ, got, err, want) + } + } +} + +// FuzzDecoder decodes a frame carrying the sample payload. Whatever decodes +// must re-encode to the same bytes, every failure must be ErrMalformed, and no +// allocation may exceed the input: the payload limit is the input length and +// the item count is bounded. +func FuzzDecoder(f *testing.F) { + f.Add(readHexFixture(f, "testdata/frame_v1.hex")) + f.Add([]byte{}) + f.Fuzz(func(t *testing.T, data []byte) { + fr, err := ReadFrame(bytes.NewReader(data), uint32(len(data))) + if err != nil { + if !errors.Is(err, ErrMalformed) && err != io.EOF && err != io.ErrUnexpectedEOF { + t.Fatalf("frame error %v", err) + } + return + } + msg, err := decodeSample(t, fr.Payload) + if err != nil { + if !errors.Is(err, ErrMalformed) { + t.Fatalf("payload error %v is not ErrMalformed", err) + } + return + } + var e Encoder + msg.encode(&e) + var w bytes.Buffer + if err := WriteFrame(&w, Frame{Type: fr.Type, RequestID: fr.RequestID, Payload: e.Payload()}); err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(data, w.Bytes()) { + t.Fatalf("round trip changed the frame:\n got %x\nwant prefix of %x", w.Bytes(), data) + } + }) +} diff --git a/internal/sandboxwire/testdata/frame_v1.hex b/internal/sandboxwire/testdata/frame_v1.hex new file mode 100644 index 000000000..14921566f --- /dev/null +++ b/internal/sandboxwire/testdata/frame_v1.hex @@ -0,0 +1,18 @@ +# One frame carrying the sample payload from sandboxwire_test.go. Hex bytes; text after # is a comment. +0000003b # PayloadLength 59 +8003 # MessageType: response to request 3 +0000 # Flags +0102030405060708 # RequestID +07 # U8 7 +0102 # U16 0x0102 +03040506 # U32 0x03040506 +0708090a0b0c0d0e # U64 0x0708090a0b0c0d0e +fffffffffffffffe # I64 -2 +01 # Bool true +0002 # Enum 2 +0002 # Effect EffectPossible +01 # Present +101112131415161718191a1b1c1d1e1f # ID +00000002 # Count 2 +00000002 6162 # Bytes "ab" +00000000 # Bytes "" diff --git a/internal/sandboxwire/vocabulary.go b/internal/sandboxwire/vocabulary.go new file mode 100644 index 000000000..364ebd0a0 --- /dev/null +++ b/internal/sandboxwire/vocabulary.go @@ -0,0 +1,35 @@ +package sandboxwire + +import ( + "crypto/rand" + "encoding/hex" +) + +// ID is an opaque 16-byte identifier, such as an OperationID, AttachmentID or +// ServerInstanceID. The zero ID is invalid wherever an ID is required. +type ID [16]byte + +// NewID returns a random nonzero ID. +func NewID() ID { + var id ID + for id.IsZero() { + rand.Read(id[:]) + } + return id +} + +func (id ID) IsZero() bool { return id == ID{} } + +func (id ID) String() string { return hex.EncodeToString(id[:]) } + +// Effect says whether a failed request may have taken effect. Transport loss +// after dispatch is EffectPossible unless the server later establishes the +// result. +type Effect uint16 + +const ( + EffectNone Effect = 1 + EffectPossible Effect = 2 +) + +func (e Effect) Valid() bool { return e == EffectNone || e == EffectPossible }