This is an automated email from the ASF dual-hosted git repository.
hgruszecki pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 6180d88e6 feat(go): implement binary reader/writer (#2986)
6180d88e6 is described below
commit 6180d88e62f3fd5c9738962ce6a882e57da6e69b
Author: Chengxi Luo <[email protected]>
AuthorDate: Mon Mar 23 07:35:28 2026 -0400
feat(go): implement binary reader/writer (#2986)
---
foreign/go/internal/codec/reader.go | 217 +++++++++++++++++
foreign/go/internal/codec/reader_test.go | 358 ++++++++++++++++++++++++++++
foreign/go/internal/codec/roundtrip_test.go | 105 ++++++++
foreign/go/internal/codec/writer.go | 151 ++++++++++++
foreign/go/internal/codec/writer_test.go | 128 ++++++++++
5 files changed, 959 insertions(+)
diff --git a/foreign/go/internal/codec/reader.go
b/foreign/go/internal/codec/reader.go
new file mode 100644
index 000000000..c6b7cd8e4
--- /dev/null
+++ b/foreign/go/internal/codec/reader.go
@@ -0,0 +1,217 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+ "encoding"
+ "encoding/binary"
+ "fmt"
+ "math"
+ "runtime"
+)
+
+// Reader is a cursor over a byte slice. The first out-of-bounds read sets err;
+// all subsequent reads are no-ops. Call Err() once after all reads to check.
+type Reader struct {
+ p []byte
+ pos int
+ err error
+}
+
+func NewReader(p []byte) *Reader {
+ return &Reader{p: p}
+}
+
+// overrun sets r.err to a descriptive error message including the caller's
+// file and line number.
+func (r *Reader) overrun(need int) {
+ _, file, line, _ := runtime.Caller(2)
+ r.err = fmt.Errorf(
+ "reader: need %d bytes at offset %d, only %d remaining (%s:%d)",
+ need, r.pos, len(r.p)-r.pos, file, line)
+}
+
+func (r *Reader) U8() uint8 {
+ if r.err != nil {
+ return 0
+ }
+ if r.pos+1 > len(r.p) {
+ r.overrun(1)
+ return 0
+ }
+ v := r.p[r.pos]
+ r.pos++
+ return v
+}
+
+func (r *Reader) U16() uint16 {
+ if r.err != nil {
+ return 0
+ }
+ if r.pos+2 > len(r.p) {
+ r.overrun(2)
+ return 0
+ }
+ v := binary.LittleEndian.Uint16(r.p[r.pos : r.pos+2])
+ r.pos += 2
+ return v
+}
+
+func (r *Reader) U32() uint32 {
+ if r.err != nil {
+ return 0
+ }
+ if r.pos+4 > len(r.p) {
+ r.overrun(4)
+ return 0
+ }
+ v := binary.LittleEndian.Uint32(r.p[r.pos : r.pos+4])
+ r.pos += 4
+ return v
+}
+
+func (r *Reader) U64() uint64 {
+ if r.err != nil {
+ return 0
+ }
+ if r.pos+8 > len(r.p) {
+ r.overrun(8)
+ return 0
+ }
+ v := binary.LittleEndian.Uint64(r.p[r.pos : r.pos+8])
+ r.pos += 8
+ return v
+}
+
+func (r *Reader) F32() float32 {
+ if r.err != nil {
+ return 0
+ }
+ if r.pos+4 > len(r.p) {
+ r.overrun(4)
+ return 0
+ }
+ v := math.Float32frombits(binary.LittleEndian.Uint32(r.p[r.pos :
r.pos+4]))
+ r.pos += 4
+ return v
+}
+
+// str reads exactly n bytes and returns a copy as a string.
+func (r *Reader) str(n int) string {
+ v := string(r.p[r.pos : r.pos+n])
+ r.pos += n
+ return v
+}
+
+// raw reads exactly n bytes and returns a copy.
+func (r *Reader) raw(n int) []byte {
+ v := make([]byte, n)
+ copy(v, r.p[r.pos:r.pos+n])
+ r.pos += n
+ return v
+}
+
+// Raw reads exactly n bytes and returns a copy.
+func (r *Reader) Raw(n int) []byte {
+ if r.err != nil {
+ return nil
+ }
+ if r.pos+n > len(r.p) {
+ r.overrun(n)
+ return nil
+ }
+ return r.raw(n)
+}
+
+// Str reads exactly n bytes and returns a copy as a string. Use U8LenStr or
+// U32LenStr instead if the data is length-prefixed:
+//
+// [length: 1 byte][data: N bytes] → U8LenStr
+// [length: 4 bytes][data: N bytes] → U32LenStr
+func (r *Reader) Str(n int) string {
+ if r.err != nil {
+ return ""
+ }
+ if r.pos+n > len(r.p) {
+ r.overrun(n)
+ return ""
+ }
+ return r.str(n)
+}
+
+// U32LenStr reads a length-prefixed string where the length is a 4-byte
+// little-endian unsigned integer.
+func (r *Reader) U32LenStr() string {
+ if r.err != nil {
+ return ""
+ }
+ if r.pos+4 > len(r.p) {
+ r.overrun(4)
+ return ""
+ }
+ n := int(binary.LittleEndian.Uint32(r.p[r.pos : r.pos+4]))
+ r.pos += 4
+ if r.pos+n > len(r.p) {
+ r.overrun(n)
+ return ""
+ }
+ return r.str(n)
+}
+
+// U8LenStr reads a length-prefixed string where the length is a single byte.
+func (r *Reader) U8LenStr() string {
+ if r.err != nil {
+ return ""
+ }
+ if r.pos+1 > len(r.p) {
+ r.overrun(1)
+ return ""
+ }
+ n := int(r.p[r.pos])
+ r.pos++
+ if r.pos+n > len(r.p) {
+ r.overrun(n)
+ return ""
+ }
+ return r.str(n)
+}
+
+// Obj reads n bytes and decodes them into v.
+func (r *Reader) Obj(n int, v encoding.BinaryUnmarshaler) {
+ if r.err != nil {
+ return
+ }
+ if r.pos+n > len(r.p) {
+ r.overrun(n)
+ return
+ }
+ err := v.UnmarshalBinary(r.raw(n))
+ if err != nil {
+ _, file, line, _ := runtime.Caller(1)
+ r.err = fmt.Errorf("%w (%s:%d)", err, file, line)
+ return
+ }
+}
+
+// Remaining returns the number of unread bytes.
+func (r *Reader) Remaining() int {
+ return len(r.p) - r.pos
+}
+
+// Err returns the first error encountered during reading, or nil.
+func (r *Reader) Err() error { return r.err }
diff --git a/foreign/go/internal/codec/reader_test.go
b/foreign/go/internal/codec/reader_test.go
new file mode 100644
index 000000000..3f5085484
--- /dev/null
+++ b/foreign/go/internal/codec/reader_test.go
@@ -0,0 +1,358 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "math"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// --- byte-construction helpers ---
+
+func u16le(v uint16) []byte {
+ b := make([]byte, 2)
+ binary.LittleEndian.PutUint16(b, v)
+ return b
+}
+
+func u32le(v uint32) []byte {
+ b := make([]byte, 4)
+ binary.LittleEndian.PutUint32(b, v)
+ return b
+}
+
+func u64le(v uint64) []byte {
+ b := make([]byte, 8)
+ binary.LittleEndian.PutUint64(b, v)
+ return b
+}
+
+func cat(slices ...[]byte) []byte {
+ var out []byte
+ for _, s := range slices {
+ out = append(out, s...)
+ }
+ return out
+}
+
+// testPoint is a simple type that implements BinaryMarshaler/BinaryUnmarshaler
+// as [x, y] two-byte encoding.
+type testPoint struct {
+ x, y uint8
+}
+
+func (p *testPoint) MarshalBinary() ([]byte, error) {
+ return []byte{p.x, p.y}, nil
+}
+
+func (p *testPoint) UnmarshalBinary(b []byte) error {
+ if len(b) < 2 {
+ return fmt.Errorf("testPoint: need 2 bytes, got %d", len(b))
+ }
+ p.x = b[0]
+ p.y = b[1]
+ return nil
+}
+
+// errMarshaler always returns an error from MarshalBinary.
+type errMarshaler struct{}
+
+func (errMarshaler) MarshalBinary() ([]byte, error) {
+ return nil, errors.New("marshal error")
+}
+
+// errUnmarshaler always returns an error from UnmarshalBinary.
+type errUnmarshaler struct{}
+
+func (e *errUnmarshaler) UnmarshalBinary(_ []byte) error {
+ return errors.New("unmarshal error")
+}
+
+// TestReader_reads exercises every read method in sequence.
+func TestReader_reads(t *testing.T) {
+ const wantU8 uint8 = math.MaxUint8
+ const wantU16 uint16 = math.MaxUint16
+ const wantU32 uint32 = math.MaxUint32
+ const wantU64 uint64 = math.MaxUint64
+ const wantF32 float32 = math.Pi
+ const wantRem = 1
+
+ wantStr := "str"
+ wantU32LenStr := "uint32"
+ wantU8LenStr := "uint8"
+ wantRaw := []byte{0xDE, 0xAD}
+ wantObj := testPoint{1, 2}
+
+ payload := cat(
+ []byte{wantU8}, // U8
+ u16le(wantU16), // U16
+ u32le(wantU32), // U32
+ u64le(wantU64), // U64
+ u32le(math.Float32bits(wantF32)), // F32
+ []byte(wantStr), //
Str(len(wantStr))
+ u32le(uint32(len(wantU32LenStr))), []byte(wantU32LenStr), //
U32LenStr
+ []byte{uint8(len(wantU8LenStr))}, []byte(wantU8LenStr), //
U8LenStr
+ wantRaw, // Raw(len(wantRaw))
+ []byte{1, 2}, // Obj(testPoint{1, 2})
+ []byte{0xFF}, // wantRem trailing bytes for Remaining()
+ )
+
+ r := NewReader(payload)
+ u8 := r.U8()
+ u16 := r.U16()
+ u32 := r.U32()
+ u64 := r.U64()
+ f32 := r.F32()
+ str := r.Str(len(wantStr))
+ u32LenStr := r.U32LenStr()
+ u8LenStr := r.U8LenStr()
+ raw := r.Raw(len(wantRaw))
+ var obj testPoint
+ r.Obj(2, &obj)
+ rem := r.Remaining()
+ if err := r.Err(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if u8 != wantU8 {
+ t.Errorf("U8: got %#x, want %#x", u8, wantU8)
+ }
+ if u16 != wantU16 {
+ t.Errorf("U16: got %#x, want %#x", u16, wantU16)
+ }
+ if u32 != wantU32 {
+ t.Errorf("U32: got %#x, want %#x", u32, wantU32)
+ }
+ if u64 != wantU64 {
+ t.Errorf("U64: got %#x, want %#x", u64, wantU64)
+ }
+ if f32 != wantF32 {
+ t.Errorf("F32: got %v, want %v", f32, wantF32)
+ }
+ if str != wantStr {
+ t.Errorf("Str: got %q, want %q", str, wantStr)
+ }
+ if u32LenStr != wantU32LenStr {
+ t.Errorf("U32LenStr: got %q, want %q", u32LenStr, wantU32LenStr)
+ }
+ if u8LenStr != wantU8LenStr {
+ t.Errorf("U8LenStr: got %q, want %q", u8LenStr, wantU8LenStr)
+ }
+ if !bytes.Equal(raw, wantRaw) {
+ t.Errorf("Raw: got %v, want %v", raw, wantRaw)
+ }
+ if obj != wantObj {
+ t.Errorf("Obj: got %v, want %v", obj, wantObj)
+ }
+ if rem != wantRem {
+ t.Errorf("Remaining: got %d, want %d", rem, wantRem)
+ }
+}
+
+// TestReader_truncation verifies that every read method returns a descriptive
error
+// when the buffer is too short, including mid-sequence truncation.
+func TestReader_truncation(t *testing.T) {
+ cases := []struct {
+ name string
+ payload []byte
+ read func(*Reader)
+ }{
+ {"U8", []byte{}, func(r *Reader) { r.U8() }},
+ {"U16", []byte{0x01}, func(r *Reader) { r.U16() }},
// 1 byte, need 2
+ {"U32", []byte{0x01, 0x02, 0x03}, func(r *Reader) { r.U32() }},
// 3 bytes, need 4
+ {"U64", []byte{0x01, 0x02, 0x03, 0x04}, func(r *Reader) {
r.U64() }}, // 4 bytes, need 8
+ {"F32", []byte{0x01, 0x02, 0x03}, func(r *Reader) { r.F32() }},
// 3 bytes, need 4
+ {"Str", []byte("hi"), func(r *Reader) { r.Str(5) }},
// claims 5, has 2
+ {"Raw", []byte("hi"), func(r *Reader) { r.Raw(5) }},
// claims 5, has 2
+ {"Obj", []byte{1}, func(r *Reader) {
+ var p testPoint
+ r.Obj(2, &p)
+ }},
+ {"U32LenStr/short-len-prefix", []byte{0x05, 0x00}, func(r
*Reader) { r.U32LenStr() }}, // len prefix needs 4 bytes, got 2
+ {"U32LenStr/short-body", cat(u32le(10), []byte("short")),
func(r *Reader) { r.U32LenStr() }}, // claims 10, has 5
+ {"U8LenStr/short-len-prefix", []byte{}, func(r *Reader) {
r.U8LenStr() }}, // len prefix needs 1 byte, got 0
+ {"U8LenStr/short-body", cat([]byte{10}, []byte("short")),
func(r *Reader) { r.U8LenStr() }}, // claims 10, has 5
+ {"mid-sequence", cat(u32le(1), []byte{0xFF}), func(r *Reader) {
r.U32(); r.U32() }},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := NewReader(tc.payload)
+ tc.read(r)
+ err := r.Err()
+ if err == nil || !strings.HasPrefix(err.Error(),
"reader: need ") {
+ t.Fatalf("got %v, want overrun error", err)
+ }
+ })
+ }
+}
+
+// TestReader_errSentinel verifies that once an error is set, all subsequent
+// read methods are no-ops and return zero values without overwriting the
error.
+func TestReader_errSentinel(t *testing.T) {
+ r := NewReader([]byte{})
+ r.U8() // triggers overrun, sets r.err
+ if r.Err() == nil {
+ t.Fatal("expected error after overrun, got nil")
+ }
+ err := r.Err()
+
+ if v := r.U8(); v != 0 {
+ t.Errorf("U8: got %v, want 0", v)
+ }
+ if v := r.U16(); v != 0 {
+ t.Errorf("U16: got %v, want 0", v)
+ }
+ if v := r.U32(); v != 0 {
+ t.Errorf("U32: got %v, want 0", v)
+ }
+ if v := r.U64(); v != 0 {
+ t.Errorf("U64: got %v, want 0", v)
+ }
+ if v := r.F32(); v != 0 {
+ t.Errorf("F32: got %v, want 0", v)
+ }
+ if v := r.Str(1); v != "" {
+ t.Errorf("Str: got %q, want empty", v)
+ }
+ if v := r.Raw(1); v != nil {
+ t.Errorf("Raw: got %v, want nil", v)
+ }
+ if v := r.U32LenStr(); v != "" {
+ t.Errorf("U32LenStr: got %q, want empty", v)
+ }
+ if v := r.U8LenStr(); v != "" {
+ t.Errorf("U8LenStr: got %q, want empty", v)
+ }
+ var p testPoint
+ r.Obj(2, &p)
+ if p != (testPoint{}) {
+ t.Errorf("Obj: got %v, want zero value", p)
+ }
+ if r.Err() != err {
+ t.Errorf("error was overwritten: got %v, want %v", r.Err(), err)
+ }
+}
+
+// TestReader_Obj_unmarshalError verifies that Reader.Obj propagates an error
+// returned by UnmarshalBinary.
+func TestReader_Obj_unmarshalError(t *testing.T) {
+ r := NewReader([]byte{1, 2})
+ _, file, line, _ := runtime.Caller(0)
+ r.Obj(2, &errUnmarshaler{})
+ checkLoc(t, r.Err(), file, line+1)
+}
+
+// TestReader_overrun_error_location verifies that the error message contains
+// the file and line of the call site that triggered the overrun, for every
+// public read method.
+func TestReader_overrun_error_location(t *testing.T) {
+ cases := []struct {
+ name string
+ payload []byte
+ fn func(r *Reader) (wantFile string, wantLine int)
+ }{
+ {"U8", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U8()
+ return file, line + 1
+ }},
+ {"U16", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U16()
+ return file, line + 1
+ }},
+ {"U32", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U32()
+ return file, line + 1
+ }},
+ {"U64", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U64()
+ return file, line + 1
+ }},
+ {"F32", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.F32()
+ return file, line + 1
+ }},
+ {"Str", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.Str(1)
+ return file, line + 1
+ }},
+ {"Raw", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.Raw(1)
+ return file, line + 1
+ }},
+ {"Obj", []byte{1}, func(r *Reader) (string, int) {
+ var p testPoint
+ _, file, line, _ := runtime.Caller(0)
+ r.Obj(2, &p)
+ return file, line + 1
+ }},
+ {"U32LenStr/prefix", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U32LenStr()
+ return file, line + 1
+ }},
+ {"U32LenStr/body", cat(u32le(100), []byte("short")), func(r
*Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U32LenStr()
+ return file, line + 1
+ }},
+ {"U8LenStr/prefix", []byte{}, func(r *Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U8LenStr()
+ return file, line + 1
+ }},
+ {"U8LenStr/body", cat([]byte{100}, []byte("short")), func(r
*Reader) (string, int) {
+ _, file, line, _ := runtime.Caller(0)
+ r.U8LenStr()
+ return file, line + 1
+ }},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := NewReader(tc.payload)
+ wantFile, wantLine := tc.fn(r)
+ checkLoc(t, r.Err(), wantFile, wantLine)
+ })
+ }
+}
+
+func checkLoc(t *testing.T, err error, wantFile string, wantLine int) {
+ t.Helper()
+ if err == nil {
+ t.Error("expected error, got nil")
+ return
+ }
+ wantLoc := fmt.Sprintf("%s:%d", wantFile, wantLine)
+ if !strings.Contains(err.Error(), wantLoc) {
+ t.Errorf("error %q does not contain location %q", err.Error(),
wantLoc)
+ }
+}
diff --git a/foreign/go/internal/codec/roundtrip_test.go
b/foreign/go/internal/codec/roundtrip_test.go
new file mode 100644
index 000000000..ea0c52b48
--- /dev/null
+++ b/foreign/go/internal/codec/roundtrip_test.go
@@ -0,0 +1,105 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+ "bytes"
+ "math"
+ "testing"
+)
+
+// Test_RoundTrip writes every method in sequence then reads back with
+// Reader and verifies the values survive the round-trip.
+func Test_RoundTrip(t *testing.T) {
+ const wantU8 uint8 = math.MaxUint8
+ const wantU16 uint16 = math.MaxUint16
+ const wantU32 uint32 = math.MaxUint32
+ const wantU64 uint64 = math.MaxUint64
+ const wantF32 float32 = math.Pi
+
+ wantStr := "str"
+ wantU32LenStr := "uint32"
+ wantU8LenStr := "uint8"
+ wantRaw := []byte{0x01, 0x02, 0x03}
+ wantObj := testPoint{x: 1, y: 2}
+
+ w := NewWriter()
+ w.U8(wantU8)
+ w.U16(wantU16)
+ w.U32(wantU32)
+ w.U64(wantU64)
+ w.F32(wantF32)
+ w.Str(wantStr)
+ w.U8LenStr(wantU8LenStr)
+ w.U32LenStr(wantU32LenStr)
+ w.Raw(wantRaw)
+ w.Obj(&wantObj)
+ if err := w.Err(); err != nil {
+ t.Fatalf("unexpected write error: %v", err)
+ }
+
+ r := NewReader(w.Bytes())
+ u8 := r.U8()
+ u16 := r.U16()
+ u32 := r.U32()
+ u64 := r.U64()
+ f32 := r.F32()
+ str := r.Str(len(wantStr))
+ u8LenStr := r.U8LenStr()
+ u32LenStr := r.U32LenStr()
+ raw := r.Raw(len(wantRaw))
+ var obj testPoint
+ r.Obj(2, &obj)
+ if r.Remaining() != 0 {
+ t.Errorf("unexpected trailing bytes: %d", r.Remaining())
+ }
+ if err := r.Err(); err != nil {
+ t.Fatalf("unexpected read error: %v", err)
+ }
+
+ if u8 != wantU8 {
+ t.Errorf("U8: got %#x, want %#x", u8, wantU8)
+ }
+ if u16 != wantU16 {
+ t.Errorf("U16: got %#x, want %#x", u16, wantU16)
+ }
+ if u32 != wantU32 {
+ t.Errorf("U32: got %#x, want %#x", u32, wantU32)
+ }
+ if u64 != wantU64 {
+ t.Errorf("U64: got %#x, want %#x", u64, wantU64)
+ }
+ if f32 != wantF32 {
+ t.Errorf("F32: got %v, want %v", f32, wantF32)
+ }
+ if str != wantStr {
+ t.Errorf("Str: got %q, want %q", str, wantStr)
+ }
+ if u8LenStr != wantU8LenStr {
+ t.Errorf("U8LenStr: got %q, want %q", u8LenStr, wantU8LenStr)
+ }
+ if u32LenStr != wantU32LenStr {
+ t.Errorf("U32LenStr: got %q, want %q", u32LenStr, wantU32LenStr)
+ }
+ if !bytes.Equal(raw, wantRaw) {
+ t.Errorf("Raw: got %v, want %v", raw, wantRaw)
+ }
+ if obj != wantObj {
+ t.Errorf("Obj: got %+v, want %+v", obj, wantObj)
+ }
+}
diff --git a/foreign/go/internal/codec/writer.go
b/foreign/go/internal/codec/writer.go
new file mode 100644
index 000000000..353aeac31
--- /dev/null
+++ b/foreign/go/internal/codec/writer.go
@@ -0,0 +1,151 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+ "encoding"
+ "encoding/binary"
+ "fmt"
+ "math"
+ "runtime"
+)
+
+// Writer appends encoded values to a growing byte slice. The first encoding
+// error sets err; all subsequent writes are no-ops. Call Err() once after all
+// writes to check.
+type Writer struct {
+ p []byte
+ err error
+}
+
+// NewWriter returns a new Writer with an empty internal buffer.
+// Use NewWriterCap instead if the final size is known.
+func NewWriter() *Writer {
+ return &Writer{}
+}
+
+// NewWriterCap returns a Writer with its internal buffer pre-allocated to n
+// bytes. Use this when the final size is known in advance to avoid
+// reallocations.
+func NewWriterCap(n int) *Writer {
+ return &Writer{p: make([]byte, 0, n)}
+}
+
+func (w *Writer) U8(v uint8) {
+ if w.err != nil {
+ return
+ }
+ w.p = append(w.p, v)
+}
+
+func (w *Writer) U16(v uint16) {
+ if w.err != nil {
+ return
+ }
+ w.p = binary.LittleEndian.AppendUint16(w.p, v)
+}
+
+func (w *Writer) U32(v uint32) {
+ if w.err != nil {
+ return
+ }
+ w.p = binary.LittleEndian.AppendUint32(w.p, v)
+}
+
+func (w *Writer) U64(v uint64) {
+ if w.err != nil {
+ return
+ }
+ w.p = binary.LittleEndian.AppendUint64(w.p, v)
+}
+
+func (w *Writer) F32(v float32) {
+ if w.err != nil {
+ return
+ }
+ w.p = binary.LittleEndian.AppendUint32(w.p, math.Float32bits(v))
+}
+
+// Str writes a string with no length prefix. Use U8LenStr or U32LenStr
+// instead if the reader expects a length prefix.
+func (w *Writer) Str(v string) {
+ if w.err != nil {
+ return
+ }
+ w.str(v)
+}
+
+// U32LenStr writes a length-prefixed string where the length is a 4-byte
+// little-endian unsigned integer.
+func (w *Writer) U32LenStr(v string) {
+ if w.err != nil {
+ return
+ }
+ w.p = binary.LittleEndian.AppendUint32(w.p, uint32(len(v)))
+ w.str(v)
+}
+
+// U8LenStr writes a length-prefixed string where the length is a single byte.
+// Sets w.err if len(v) exceeds 255.
+func (w *Writer) U8LenStr(v string) {
+ if w.err != nil {
+ return
+ }
+ if len(v) > math.MaxUint8 {
+ _, file, line, _ := runtime.Caller(1)
+ w.err = fmt.Errorf("string length %d exceeds 255 (%s:%d)",
len(v), file, line)
+ return
+ }
+ w.p = append(w.p, uint8(len(v)))
+ w.str(v)
+}
+
+func (w *Writer) str(v string) {
+ w.p = append(w.p, v...)
+}
+
+// Raw appends a raw byte slice with no length prefix.
+func (w *Writer) Raw(v []byte) {
+ if w.err != nil {
+ return
+ }
+ w.p = append(w.p, v...)
+}
+
+// Obj encodes v and appends the result to the buffer.
+func (w *Writer) Obj(v encoding.BinaryMarshaler) {
+ if w.err != nil {
+ return
+ }
+ b, err := v.MarshalBinary()
+ if err != nil {
+ _, file, line, _ := runtime.Caller(1)
+ w.err = fmt.Errorf("%w (%s:%d)", err, file, line)
+ return
+ }
+ w.p = append(w.p, b...)
+}
+
+// Bytes returns the accumulated buffer directly. The slice is valid only
+// until the next write. The caller must not mutate the returned slice.
+func (w *Writer) Bytes() []byte {
+ return w.p
+}
+
+// Err returns the first error encountered during writing, or nil.
+func (w *Writer) Err() error { return w.err }
diff --git a/foreign/go/internal/codec/writer_test.go
b/foreign/go/internal/codec/writer_test.go
new file mode 100644
index 000000000..035428164
--- /dev/null
+++ b/foreign/go/internal/codec/writer_test.go
@@ -0,0 +1,128 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+ "bytes"
+ "math"
+ "regexp"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// TestWriter_writes verifies that every write method encodes the expected
bytes.
+func TestWriter_writes(t *testing.T) {
+ cases := []struct {
+ name string
+ write func(*Writer)
+ want []byte
+ }{
+ {"U8", func(w *Writer) { w.U8(0xAB) }, []byte{0xAB}},
+ {"U16", func(w *Writer) { w.U16(0x0102) }, []byte{0x02, 0x01}},
+ {"U32", func(w *Writer) { w.U32(0x01020304) }, []byte{0x04,
0x03, 0x02, 0x01}},
+ {"U64", func(w *Writer) { w.U64(0x0102030405060708) },
[]byte{0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}},
+ {"F32", func(w *Writer) { w.F32(1.0) }, []byte{0x00, 0x00,
0x80, 0x3F}},
+ {"Str", func(w *Writer) { w.Str("ab") }, []byte{'a', 'b'}},
+ {"U32LenStr", func(w *Writer) { w.U32LenStr("ab") },
[]byte{0x02, 0x00, 0x00, 0x00, 'a', 'b'}},
+ {"U8LenStr", func(w *Writer) { w.U8LenStr("ab") }, []byte{0x02,
'a', 'b'}},
+ {"Raw", func(w *Writer) { w.Raw([]byte{0xDE, 0xAD}) },
[]byte{0xDE, 0xAD}},
+ {"Obj", func(w *Writer) { w.Obj(&testPoint{1, 2}) },
[]byte{0x01, 0x02}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ w := NewWriter()
+ tc.write(w)
+ if err := w.Err(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := w.Bytes(); !bytes.Equal(got, tc.want) {
+ t.Errorf("got %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestWriter_errSentinel verifies that once an error is set, all subsequent
+// write methods are no-ops and the buffer remains empty.
+func TestWriter_errSentinel(t *testing.T) {
+ w := NewWriter()
+ w.Obj(errMarshaler{})
+ if w.Err() == nil {
+ t.Fatal("expected error after errMarshaler, got nil")
+ }
+ err := w.Err()
+ w.U8(1)
+ w.U16(2)
+ w.U32(3)
+ w.U64(4)
+ w.F32(5)
+ w.Str("x")
+ w.U32LenStr("y")
+ w.U8LenStr("z")
+ w.Raw([]byte{0xFF})
+ w.Obj(&testPoint{1, 2})
+ if got := w.Bytes(); len(got) != 0 {
+ t.Errorf("expected empty buffer after error, got %d bytes: %v",
len(got), got)
+ }
+ if w.Err() != err {
+ t.Errorf("error was overwritten: got %v, want %v", w.Err(), err)
+ }
+}
+
+// TestWriter_U8LenStr_overflow verifies that U8LenStr sets w.err for strings
+// longer than 255 bytes.
+func TestWriter_U8LenStr_overflow(t *testing.T) {
+ w := NewWriter()
+ _, file, line, _ := runtime.Caller(0)
+ w.U8LenStr(strings.Repeat("a", math.MaxUint8+1))
+ err := w.Err()
+ if err == nil {
+ t.Fatal("expected error for string > 255 bytes, got nil")
+ }
+ re := regexp.MustCompile(`^string length (\d+) exceeds 255`)
+ if !re.MatchString(err.Error()) {
+ t.Errorf("unexpected error message: %v", err)
+ }
+ checkLoc(t, err, file, line+1)
+}
+
+// TestWriterCap_noAlloc verifies that NewWriterCap avoids
+// reallocations when the provided capacity is sufficient.
+func TestWriterCap_noAlloc(t *testing.T) {
+ const n = 4 + 1 + len("name") // U32 + U8LenStr("name")
+ w := NewWriterCap(n)
+ capBefore := cap(w.p)
+ w.U32(42)
+ w.U8LenStr("name")
+ if cap(w.p) != capBefore {
+ t.Errorf("reallocation occurred: cap before=%d, after=%d",
capBefore, cap(w.p))
+ }
+ if len(w.p) != n {
+ t.Errorf("unexpected length: got %d, want %d", len(w.p), n)
+ }
+}
+
+// TestWriter_Obj_error_location verifies that the error message contains
+// the file and line of the call site that trigger the error.
+func TestWriter_Obj_error_location(t *testing.T) {
+ w := NewWriter()
+ _, file, line, _ := runtime.Caller(0)
+ w.Obj(&errMarshaler{})
+ checkLoc(t, w.Err(), file, line+1)
+}