aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--format/sideband64k/append.go40
-rw-r--r--format/sideband64k/append_helpers_test.go30
-rw-r--r--format/sideband64k/append_preserves_dst_on_error_test.go34
-rw-r--r--format/sideband64k/band.go13
-rw-r--r--format/sideband64k/chunk_writer.go64
-rw-r--r--format/sideband64k/chunk_writer_write_and_read_from_test.go52
-rw-r--r--format/sideband64k/constants.go10
-rw-r--r--format/sideband64k/decoder.go155
-rw-r--r--format/sideband64k/decoder_data_control_and_keepalive_test.go71
-rw-r--r--format/sideband64k/decoder_invalid_band_test.go21
-rw-r--r--format/sideband64k/decoder_invalid_empty_payload_test.go21
-rw-r--r--format/sideband64k/decoder_malformed_pktline_test.go34
-rw-r--r--format/sideband64k/decoder_partial_read_test.go30
-rw-r--r--format/sideband64k/decoder_peek_test.go32
-rw-r--r--format/sideband64k/decoder_resync_after_over_max_data_test.go45
-rw-r--r--format/sideband64k/decoder_resync_after_over_wire_max_test.go37
-rw-r--r--format/sideband64k/decoder_unexpected_eof_test.go21
-rw-r--r--format/sideband64k/doc.go2
-rw-r--r--format/sideband64k/encoder.go97
-rw-r--r--format/sideband64k/encoder_buffered_flush_behavior_test.go48
-rw-r--r--format/sideband64k/encoder_partial_write_test.go41
-rw-r--r--format/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go23
-rw-r--r--format/sideband64k/encoder_writes_frames_test.go45
-rw-r--r--format/sideband64k/errors.go30
-rw-r--r--format/sideband64k/frame.go12
-rw-r--r--format/sideband64k/frame_type.go19
-rw-r--r--format/sideband64k/helpers_test.go44
27 files changed, 1071 insertions, 0 deletions
diff --git a/format/sideband64k/append.go b/format/sideband64k/append.go
new file mode 100644
index 00000000..59dbdf43
--- /dev/null
+++ b/format/sideband64k/append.go
@@ -0,0 +1,40 @@
+package sideband64k
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+)
+
+// AppendBand appends one side-band-64k data frame to dst.
+func AppendBand(dst []byte, band Band, payload []byte) ([]byte, error) {
+ if !validBand(band) {
+ return dst, fmt.Errorf("%w: %d", ErrInvalidBand, band)
+ }
+
+ maxData := effectiveMaxData(DataMax)
+ if len(payload) > maxData {
+ return dst, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(payload), maxData)
+ }
+
+ framed := make([]byte, len(payload)+1)
+ framed[0] = byte(band)
+ copy(framed[1:], payload)
+
+ return pktline.AppendData(dst, framed)
+}
+
+// AppendData appends one band-1 data frame to dst.
+func AppendData(dst, payload []byte) ([]byte, error) {
+ return AppendBand(dst, BandData, payload)
+}
+
+// AppendProgress appends one band-2 progress frame to dst.
+func AppendProgress(dst, payload []byte) ([]byte, error) {
+ return AppendBand(dst, BandProgress, payload)
+}
+
+// AppendError appends one band-3 error frame to dst.
+func AppendError(dst, payload []byte) ([]byte, error) {
+ return AppendBand(dst, BandError, payload)
+}
diff --git a/format/sideband64k/append_helpers_test.go b/format/sideband64k/append_helpers_test.go
new file mode 100644
index 00000000..17cd7493
--- /dev/null
+++ b/format/sideband64k/append_helpers_test.go
@@ -0,0 +1,30 @@
+package sideband64k_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestAppendHelpers(t *testing.T) {
+ t.Parallel()
+
+ out, err := sideband64k.AppendData(nil, []byte("a"))
+ if err != nil {
+ t.Fatalf("AppendData: %v", err)
+ }
+
+ out, err = sideband64k.AppendProgress(out, []byte("b"))
+ if err != nil {
+ t.Fatalf("AppendProgress: %v", err)
+ }
+
+ out, err = sideband64k.AppendError(out, []byte("c"))
+ if err != nil {
+ t.Fatalf("AppendError: %v", err)
+ }
+
+ if got, want := string(out), "0006\x01a0006\x02b0006\x03c"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/format/sideband64k/append_preserves_dst_on_error_test.go b/format/sideband64k/append_preserves_dst_on_error_test.go
new file mode 100644
index 00000000..a327326d
--- /dev/null
+++ b/format/sideband64k/append_preserves_dst_on_error_test.go
@@ -0,0 +1,34 @@
+package sideband64k_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestAppendBandPreservesDstOnError(t *testing.T) {
+ t.Parallel()
+
+ orig := []byte("seed")
+ dst := append([]byte(nil), orig...)
+
+ out, err := sideband64k.AppendBand(dst, 4, []byte("x"))
+ if !errors.Is(err, sideband64k.ErrInvalidBand) {
+ t.Fatalf("got err %v, want ErrInvalidBand", err)
+ }
+
+ if !bytes.Equal(out, orig) {
+ t.Fatalf("got %q, want %q", string(out), string(orig))
+ }
+
+ out, err = sideband64k.AppendData(dst, bytes.Repeat([]byte{'x'}, sideband64k.DataMax+1))
+ if !errors.Is(err, sideband64k.ErrTooLarge) {
+ t.Fatalf("got err %v, want ErrTooLarge", err)
+ }
+
+ if !bytes.Equal(out, orig) {
+ t.Fatalf("got %q, want %q", string(out), string(orig))
+ }
+}
diff --git a/format/sideband64k/band.go b/format/sideband64k/band.go
new file mode 100644
index 00000000..73c61fd8
--- /dev/null
+++ b/format/sideband64k/band.go
@@ -0,0 +1,13 @@
+package sideband64k
+
+// Band identifies the sideband stream within a pkt-line data frame.
+type Band uint8
+
+const (
+ // BandData carries primary payload bytes.
+ BandData Band = 1
+ // BandProgress carries progress or informational messages.
+ BandProgress Band = 2
+ // BandError carries fatal error messages.
+ BandError Band = 3
+)
diff --git a/format/sideband64k/chunk_writer.go b/format/sideband64k/chunk_writer.go
new file mode 100644
index 00000000..f95f75d8
--- /dev/null
+++ b/format/sideband64k/chunk_writer.go
@@ -0,0 +1,64 @@
+package sideband64k
+
+import "io"
+
+// ChunkWriter packetizes arbitrary stream bytes into side-band-64k data frames
+// for one fixed band.
+//
+// It never writes control packets automatically.
+type ChunkWriter struct {
+ enc *Encoder
+ band Band
+}
+
+// NewChunkWriter creates a chunking adapter over enc for one band.
+func NewChunkWriter(enc *Encoder, band Band) *ChunkWriter {
+ return &ChunkWriter{enc: enc, band: band}
+}
+
+// Write splits p into sideband frames not larger than enc's maxData.
+func (cw *ChunkWriter) Write(p []byte) (int, error) {
+ total := 0
+ maxData := cw.enc.effectiveMaxData()
+
+ for len(p) > 0 {
+ n := min(len(p), maxData)
+
+ err := cw.enc.WriteBand(cw.band, p[:n])
+ if err != nil {
+ return total, err
+ }
+
+ total += n
+ p = p[n:]
+ }
+
+ return total, nil
+}
+
+// ReadFrom reads from r and writes sideband frames to the encoder.
+func (cw *ChunkWriter) ReadFrom(r io.Reader) (int64, error) {
+ buf := make([]byte, cw.enc.effectiveMaxData())
+
+ var total int64
+
+ for {
+ n, err := r.Read(buf)
+ if n > 0 {
+ werr := cw.enc.WriteBand(cw.band, buf[:n])
+ if werr != nil {
+ return total, werr
+ }
+
+ total += int64(n)
+ }
+
+ if err != nil {
+ if err == io.EOF {
+ return total, nil
+ }
+
+ return total, err
+ }
+ }
+}
diff --git a/format/sideband64k/chunk_writer_write_and_read_from_test.go b/format/sideband64k/chunk_writer_write_and_read_from_test.go
new file mode 100644
index 00000000..7d475015
--- /dev/null
+++ b/format/sideband64k/chunk_writer_write_and_read_from_test.go
@@ -0,0 +1,52 @@
+package sideband64k_test
+
+import (
+ "bufio"
+ "bytes"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestChunkWriterWriteAndReadFrom(t *testing.T) {
+ t.Parallel()
+
+ var out bytes.Buffer
+
+ bw := bufio.NewWriter(&out)
+ enc := sideband64k.NewEncoder(bw)
+ enc.SetMaxData(3)
+
+ cw := sideband64k.NewChunkWriter(enc, sideband64k.BandProgress)
+
+ n, err := cw.Write([]byte("abcdefg"))
+ if err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if n != 7 {
+ t.Fatalf("Write n=%d, want 7", n)
+ }
+ if err := enc.FlushIO(); err != nil {
+ t.Fatalf("FlushIO: %v", err)
+ }
+ if got, want := out.String(), "0008\x02abc0008\x02def0006\x02g"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+
+ out.Reset()
+
+ rn, err := cw.ReadFrom(strings.NewReader("wxyz"))
+ if err != nil {
+ t.Fatalf("ReadFrom: %v", err)
+ }
+ if rn != 4 {
+ t.Fatalf("ReadFrom n=%d, want 4", rn)
+ }
+ if err := enc.FlushIO(); err != nil {
+ t.Fatalf("FlushIO: %v", err)
+ }
+ if got, want := out.String(), "0008\x02wxy0006\x02z"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/format/sideband64k/constants.go b/format/sideband64k/constants.go
new file mode 100644
index 00000000..40077761
--- /dev/null
+++ b/format/sideband64k/constants.go
@@ -0,0 +1,10 @@
+package sideband64k
+
+import "codeberg.org/lindenii/furgit/format/pktline"
+
+const (
+ // PacketMax is the maximum on-wire pkt-line size used by side-band-64k.
+ PacketMax = pktline.LargePacketMax
+ // DataMax is the maximum sideband payload size excluding the 1-byte band designator.
+ DataMax = pktline.LargePacketDataMax - 1
+)
diff --git a/format/sideband64k/decoder.go b/format/sideband64k/decoder.go
new file mode 100644
index 00000000..8c2b48d5
--- /dev/null
+++ b/format/sideband64k/decoder.go
@@ -0,0 +1,155 @@
+package sideband64k
+
+import (
+ "fmt"
+ "io"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+)
+
+// ReadOptions controls sideband decoding behavior.
+type ReadOptions struct {
+ // ChompLF removes one trailing '\n' from FrameData payloads only.
+ ChompLF bool
+}
+
+// Decoder reads side-band-64k frames from an io.Reader.
+//
+// It preserves frame boundaries and supports one-frame lookahead via
+// PeekFrame.
+type Decoder struct {
+ dec *pktline.Decoder
+ maxData int
+ opts ReadOptions
+
+ peeked bool
+ peek Frame
+ peekErr error
+}
+
+// NewDecoder creates a decoder over r.
+func NewDecoder(r io.Reader, opts ReadOptions) *Decoder {
+ d := &Decoder{
+ dec: pktline.NewDecoder(r, pktline.ReadOptions{}),
+ maxData: DataMax,
+ opts: opts,
+ }
+ d.dec.SetMaxData(pktline.LargePacketDataMax)
+ return d
+}
+
+// SetMaxData sets maximum payload size accepted for one sideband data packet.
+//
+// Non-positive n resets to DataMax.
+func (d *Decoder) SetMaxData(n int) {
+ if n <= 0 {
+ d.maxData = DataMax
+ return
+ }
+
+ d.maxData = n
+}
+
+// ReadFrame reads one frame.
+func (d *Decoder) ReadFrame() (Frame, error) {
+ if d.peeked {
+ d.peeked = false
+ return cloneFrame(d.peek), d.peekErr
+ }
+
+ return d.readFrame()
+}
+
+// PeekFrame returns the next frame without consuming it.
+func (d *Decoder) PeekFrame() (Frame, error) {
+ if !d.peeked {
+ d.peek, d.peekErr = d.readFrame()
+ d.peeked = true
+ }
+
+ return cloneFrame(d.peek), d.peekErr
+}
+
+func (d *Decoder) readFrame() (Frame, error) {
+ f, err := d.dec.ReadFrame()
+ if err != nil {
+ return Frame{}, err
+ }
+
+ switch f.Type {
+ case pktline.PacketFlush:
+ return Frame{Type: FrameFlush}, nil
+ case pktline.PacketDelim:
+ return Frame{Type: FrameDelim}, nil
+ case pktline.PacketResponseEnd:
+ return Frame{Type: FrameResponseEnd}, nil
+ case pktline.PacketData:
+ if len(f.Payload) == 0 {
+ return Frame{}, &ProtocolError{Reason: "missing sideband designator"}
+ }
+
+ payload := f.Payload[1:]
+ if len(payload) > d.effectiveMaxData() {
+ return Frame{}, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(payload), d.effectiveMaxData())
+ }
+
+ band := Band(f.Payload[0])
+ if !validBand(band) {
+ return Frame{}, &ProtocolError{Reason: fmt.Sprintf("%v: %d", ErrInvalidBand, band)}
+ }
+
+ payload = append([]byte(nil), payload...)
+ if d.opts.ChompLF && band == BandData && len(payload) > 0 && payload[len(payload)-1] == '\n' {
+ payload = payload[:len(payload)-1]
+ }
+
+ return Frame{
+ Type: frameTypeForBand(band),
+ Payload: payload,
+ }, nil
+ default:
+ return Frame{}, &ProtocolError{Reason: "unknown pkt-line frame type"}
+ }
+}
+
+func (d *Decoder) effectiveMaxData() int {
+ return effectiveMaxData(d.maxData)
+}
+
+func cloneFrame(f Frame) Frame {
+ if f.Type == FrameFlush || f.Type == FrameDelim || f.Type == FrameResponseEnd {
+ return Frame{Type: f.Type}
+ }
+
+ out := Frame{Type: f.Type}
+ if f.Payload != nil {
+ out.Payload = append([]byte(nil), f.Payload...)
+ }
+
+ return out
+}
+
+func validBand(band Band) bool {
+ return band == BandData || band == BandProgress || band == BandError
+}
+
+func frameTypeForBand(band Band) FrameType {
+ switch band {
+ case BandData:
+ return FrameData
+ case BandProgress:
+ return FrameProgress
+ case BandError:
+ return FrameError
+ default:
+ panic("invalid sideband64k band")
+ }
+}
+
+func effectiveMaxData(n int) int {
+ if n <= 0 || n > DataMax {
+ return DataMax
+ }
+
+ return n
+}
diff --git a/format/sideband64k/decoder_data_control_and_keepalive_test.go b/format/sideband64k/decoder_data_control_and_keepalive_test.go
new file mode 100644
index 00000000..cabd405b
--- /dev/null
+++ b/format/sideband64k/decoder_data_control_and_keepalive_test.go
@@ -0,0 +1,71 @@
+package sideband64k_test
+
+import (
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderDataControlAndKeepalive(t *testing.T) {
+ t.Parallel()
+
+ input := "0007\x01a\n0005\x010007\x02p\n0007\x03e\n000100020000"
+ dec := sideband64k.NewDecoder(strings.NewReader(input), sideband64k.ReadOptions{ChompLF: true})
+
+ f, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #1: %v", err)
+ }
+ if f.Type != sideband64k.FrameData || string(f.Payload) != "a" {
+ t.Fatalf("frame #1 = %#v", f)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #2: %v", err)
+ }
+ if f.Type != sideband64k.FrameData || len(f.Payload) != 0 {
+ t.Fatalf("frame #2 = %#v, want empty data", f)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #3: %v", err)
+ }
+ if f.Type != sideband64k.FrameProgress || string(f.Payload) != "p\n" {
+ t.Fatalf("frame #3 = %#v", f)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #4: %v", err)
+ }
+ if f.Type != sideband64k.FrameError || string(f.Payload) != "e\n" {
+ t.Fatalf("frame #4 = %#v", f)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #5: %v", err)
+ }
+ if f.Type != sideband64k.FrameDelim {
+ t.Fatalf("frame #5 type = %v, want FrameDelim", f.Type)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #6: %v", err)
+ }
+ if f.Type != sideband64k.FrameResponseEnd {
+ t.Fatalf("frame #6 type = %v, want FrameResponseEnd", f.Type)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #7: %v", err)
+ }
+ if f.Type != sideband64k.FrameFlush {
+ t.Fatalf("frame #7 type = %v, want FrameFlush", f.Type)
+ }
+}
diff --git a/format/sideband64k/decoder_invalid_band_test.go b/format/sideband64k/decoder_invalid_band_test.go
new file mode 100644
index 00000000..5a58640c
--- /dev/null
+++ b/format/sideband64k/decoder_invalid_band_test.go
@@ -0,0 +1,21 @@
+package sideband64k_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderInvalidBand(t *testing.T) {
+ t.Parallel()
+
+ dec := sideband64k.NewDecoder(strings.NewReader("0005\x04"), sideband64k.ReadOptions{})
+ _, err := dec.ReadFrame()
+
+ var pe *sideband64k.ProtocolError
+ if !errors.As(err, &pe) {
+ t.Fatalf("got err %v, want ProtocolError", err)
+ }
+}
diff --git a/format/sideband64k/decoder_invalid_empty_payload_test.go b/format/sideband64k/decoder_invalid_empty_payload_test.go
new file mode 100644
index 00000000..679e28d3
--- /dev/null
+++ b/format/sideband64k/decoder_invalid_empty_payload_test.go
@@ -0,0 +1,21 @@
+package sideband64k_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderInvalidEmptyPayload(t *testing.T) {
+ t.Parallel()
+
+ dec := sideband64k.NewDecoder(strings.NewReader("0004"), sideband64k.ReadOptions{})
+ _, err := dec.ReadFrame()
+
+ var pe *sideband64k.ProtocolError
+ if !errors.As(err, &pe) {
+ t.Fatalf("got err %v, want ProtocolError", err)
+ }
+}
diff --git a/format/sideband64k/decoder_malformed_pktline_test.go b/format/sideband64k/decoder_malformed_pktline_test.go
new file mode 100644
index 00000000..ffa746ca
--- /dev/null
+++ b/format/sideband64k/decoder_malformed_pktline_test.go
@@ -0,0 +1,34 @@
+package sideband64k_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderInvalid0003(t *testing.T) {
+ t.Parallel()
+
+ dec := sideband64k.NewDecoder(strings.NewReader("0003"), sideband64k.ReadOptions{})
+ _, err := dec.ReadFrame()
+
+ var pe *pktline.ProtocolError
+ if !errors.As(err, &pe) {
+ t.Fatalf("got err %v, want pktline.ProtocolError", err)
+ }
+}
+
+func TestDecoderRejectsOverMaximumLength(t *testing.T) {
+ t.Parallel()
+
+ dec := sideband64k.NewDecoder(strings.NewReader("fffe"), sideband64k.ReadOptions{})
+ _, err := dec.ReadFrame()
+
+ var pe *pktline.ProtocolError
+ if !errors.As(err, &pe) {
+ t.Fatalf("got err %v, want pktline.ProtocolError", err)
+ }
+}
diff --git a/format/sideband64k/decoder_partial_read_test.go b/format/sideband64k/decoder_partial_read_test.go
new file mode 100644
index 00000000..0efd4db5
--- /dev/null
+++ b/format/sideband64k/decoder_partial_read_test.go
@@ -0,0 +1,30 @@
+package sideband64k_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderHandlesPartialReads(t *testing.T) {
+ t.Parallel()
+
+ r := &byteReader{data: []byte("0007\x02ok0000")}
+ dec := sideband64k.NewDecoder(r, sideband64k.ReadOptions{})
+
+ f, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #1: %v", err)
+ }
+ if f.Type != sideband64k.FrameProgress || string(f.Payload) != "ok" {
+ t.Fatalf("frame #1 = %#v", f)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #2: %v", err)
+ }
+ if f.Type != sideband64k.FrameFlush {
+ t.Fatalf("frame #2 = %#v", f)
+ }
+}
diff --git a/format/sideband64k/decoder_peek_test.go b/format/sideband64k/decoder_peek_test.go
new file mode 100644
index 00000000..900e714c
--- /dev/null
+++ b/format/sideband64k/decoder_peek_test.go
@@ -0,0 +1,32 @@
+package sideband64k_test
+
+import (
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderPeek(t *testing.T) {
+ t.Parallel()
+
+ dec := sideband64k.NewDecoder(strings.NewReader("0006\x01x0000"), sideband64k.ReadOptions{})
+
+ f, err := dec.PeekFrame()
+ if err != nil {
+ t.Fatalf("PeekFrame: %v", err)
+ }
+ if f.Type != sideband64k.FrameData || string(f.Payload) != "x" {
+ t.Fatalf("peek frame = %#v", f)
+ }
+
+ f.Payload[0] = 'y'
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame: %v", err)
+ }
+ if f.Type != sideband64k.FrameData || string(f.Payload) != "x" {
+ t.Fatalf("read frame = %#v", f)
+ }
+}
diff --git a/format/sideband64k/decoder_resync_after_over_max_data_test.go b/format/sideband64k/decoder_resync_after_over_max_data_test.go
new file mode 100644
index 00000000..7280c0ac
--- /dev/null
+++ b/format/sideband64k/decoder_resync_after_over_max_data_test.go
@@ -0,0 +1,45 @@
+package sideband64k_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderResyncAfterOverMaxData(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+
+ bw := bufio.NewWriter(&b)
+ enc := sideband64k.NewEncoder(bw)
+
+ if err := enc.WriteData([]byte("abcd")); err != nil {
+ t.Fatalf("WriteData #1: %v", err)
+ }
+ if err := enc.WriteData([]byte("z")); err != nil {
+ t.Fatalf("WriteData #2: %v", err)
+ }
+ if err := enc.FlushIO(); err != nil {
+ t.Fatalf("FlushIO: %v", err)
+ }
+
+ dec := sideband64k.NewDecoder(bytes.NewReader(b.Bytes()), sideband64k.ReadOptions{})
+ dec.SetMaxData(1)
+
+ _, err := dec.ReadFrame()
+ if !errors.Is(err, sideband64k.ErrTooLarge) {
+ t.Fatalf("got err %v, want ErrTooLarge", err)
+ }
+
+ f, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #2: %v", err)
+ }
+ if f.Type != sideband64k.FrameData || string(f.Payload) != "z" {
+ t.Fatalf("got frame %#v, want data z", f)
+ }
+}
diff --git a/format/sideband64k/decoder_resync_after_over_wire_max_test.go b/format/sideband64k/decoder_resync_after_over_wire_max_test.go
new file mode 100644
index 00000000..26e67d7f
--- /dev/null
+++ b/format/sideband64k/decoder_resync_after_over_wire_max_test.go
@@ -0,0 +1,37 @@
+package sideband64k_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderResyncAfterOverWireMax(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+
+ _, _ = b.WriteString("ffff")
+ _, _ = b.Write(bytes.Repeat([]byte{'a'}, 65531))
+ _, _ = b.WriteString("0006\x01z")
+
+ dec := sideband64k.NewDecoder(bytes.NewReader(b.Bytes()), sideband64k.ReadOptions{})
+
+ _, err := dec.ReadFrame()
+
+ var pe *pktline.ProtocolError
+ if !errors.As(err, &pe) {
+ t.Fatalf("got err %v, want pktline.ProtocolError", err)
+ }
+
+ f, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #2: %v", err)
+ }
+ if f.Type != sideband64k.FrameData || string(f.Payload) != "z" {
+ t.Fatalf("got frame %#v, want data z", f)
+ }
+}
diff --git a/format/sideband64k/decoder_unexpected_eof_test.go b/format/sideband64k/decoder_unexpected_eof_test.go
new file mode 100644
index 00000000..40794471
--- /dev/null
+++ b/format/sideband64k/decoder_unexpected_eof_test.go
@@ -0,0 +1,21 @@
+package sideband64k_test
+
+import (
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestDecoderUnexpectedEOF(t *testing.T) {
+ t.Parallel()
+
+ dec := sideband64k.NewDecoder(strings.NewReader("0006\x01"), sideband64k.ReadOptions{})
+
+ _, err := dec.ReadFrame()
+ if !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("got err %v, want io.ErrUnexpectedEOF", err)
+ }
+}
diff --git a/format/sideband64k/doc.go b/format/sideband64k/doc.go
new file mode 100644
index 00000000..55c33650
--- /dev/null
+++ b/format/sideband64k/doc.go
@@ -0,0 +1,2 @@
+// Package sideband64k implements Git side-band-64k multiplexing over pkt-line.
+package sideband64k
diff --git a/format/sideband64k/encoder.go b/format/sideband64k/encoder.go
new file mode 100644
index 00000000..56854abf
--- /dev/null
+++ b/format/sideband64k/encoder.go
@@ -0,0 +1,97 @@
+package sideband64k
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+)
+
+// Encoder writes side-band-64k frames to a flush-capable output transport.
+//
+// It writes exactly one frame per method call and does not auto-chunk data.
+type Encoder struct {
+ enc *pktline.Encoder
+ maxData int
+}
+
+// NewEncoder creates an encoder over w.
+func NewEncoder(w pktline.WriteFlusher) *Encoder {
+ return &Encoder{
+ enc: pktline.NewEncoder(w),
+ maxData: DataMax,
+ }
+}
+
+// SetMaxData sets the maximum payload size accepted by WriteBand.
+//
+// Non-positive n resets to DataMax.
+func (e *Encoder) SetMaxData(n int) {
+ if n <= 0 {
+ e.maxData = DataMax
+ return
+ }
+
+ e.maxData = n
+}
+
+// WriteBand writes one side-band-64k data frame for the given band.
+func (e *Encoder) WriteBand(band Band, p []byte) error {
+ if !validBand(band) {
+ return fmt.Errorf("%w: %d", ErrInvalidBand, band)
+ }
+
+ maxData := e.effectiveMaxData()
+ if len(p) > maxData {
+ return fmt.Errorf("%w: %d > %d", ErrTooLarge, len(p), maxData)
+ }
+
+ framed := make([]byte, len(p)+1)
+ framed[0] = byte(band)
+ copy(framed[1:], p)
+
+ return e.enc.WriteData(framed)
+}
+
+// WriteData writes one band-1 data frame.
+func (e *Encoder) WriteData(p []byte) error {
+ return e.WriteBand(BandData, p)
+}
+
+// WriteProgress writes one band-2 progress frame.
+func (e *Encoder) WriteProgress(p []byte) error {
+ return e.WriteBand(BandProgress, p)
+}
+
+// WriteError writes one band-3 error frame.
+func (e *Encoder) WriteError(p []byte) error {
+ return e.WriteBand(BandError, p)
+}
+
+// WriteFlush writes control frame 0000 (flush-pkt).
+func (e *Encoder) WriteFlush() error {
+ return e.enc.WriteFlush()
+}
+
+// WriteDelim writes control frame 0001 (delim-pkt).
+func (e *Encoder) WriteDelim() error {
+ return e.enc.WriteDelim()
+}
+
+// WriteResponseEnd writes control frame 0002 (response-end-pkt).
+func (e *Encoder) WriteResponseEnd() error {
+ return e.enc.WriteResponseEnd()
+}
+
+// FlushIO flushes buffered output in the underlying transport.
+func (e *Encoder) FlushIO() error {
+ return e.enc.FlushIO()
+}
+
+// WriteFlushAndFlushIO writes a flush-pkt (0000) then flushes transport I/O.
+func (e *Encoder) WriteFlushAndFlushIO() error {
+ return e.enc.WriteFlushAndFlushIO()
+}
+
+func (e *Encoder) effectiveMaxData() int {
+ return effectiveMaxData(e.maxData)
+}
diff --git a/format/sideband64k/encoder_buffered_flush_behavior_test.go b/format/sideband64k/encoder_buffered_flush_behavior_test.go
new file mode 100644
index 00000000..f804f231
--- /dev/null
+++ b/format/sideband64k/encoder_buffered_flush_behavior_test.go
@@ -0,0 +1,48 @@
+package sideband64k_test
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestEncoderBufferedFlushBehavior(t *testing.T) {
+ t.Parallel()
+
+ var out bytes.Buffer
+
+ bw := bufio.NewWriter(&out)
+ enc := sideband64k.NewEncoder(bw)
+
+ if err := enc.WriteData([]byte("hello")); err != nil {
+ t.Fatalf("WriteData: %v", err)
+ }
+ if err := enc.WriteFlush(); err != nil {
+ t.Fatalf("WriteFlush: %v", err)
+ }
+ if out.Len() != 0 {
+ t.Fatalf("WriteFlush should not flush I/O, got %q", out.String())
+ }
+ if err := enc.FlushIO(); err != nil {
+ t.Fatalf("FlushIO: %v", err)
+ }
+ if got, want := out.String(), "000a\x01hello0000"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+
+ out.Reset()
+ bw = bufio.NewWriter(&out)
+ enc = sideband64k.NewEncoder(bw)
+
+ if err := enc.WriteData([]byte("yo")); err != nil {
+ t.Fatalf("WriteData: %v", err)
+ }
+ if err := enc.WriteFlushAndFlushIO(); err != nil {
+ t.Fatalf("WriteFlushAndFlushIO: %v", err)
+ }
+ if got, want := out.String(), "0007\x01yo0000"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/format/sideband64k/encoder_partial_write_test.go b/format/sideband64k/encoder_partial_write_test.go
new file mode 100644
index 00000000..c82b1902
--- /dev/null
+++ b/format/sideband64k/encoder_partial_write_test.go
@@ -0,0 +1,41 @@
+package sideband64k_test
+
+import (
+ "io"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestEncoderHandlesPartialWrites(t *testing.T) {
+ t.Parallel()
+
+ dst := &limitWriter{maxPerWrite: 2}
+ enc := sideband64k.NewEncoder(dst)
+
+ if err := enc.WriteProgress([]byte("abc")); err != nil {
+ t.Fatalf("WriteProgress: %v", err)
+ }
+ if err := enc.WriteFlushAndFlushIO(); err != nil {
+ t.Fatalf("WriteFlushAndFlushIO: %v", err)
+ }
+
+ if got, want := dst.buf.String(), "0008\x02abc0000"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+ if dst.flushes != 1 {
+ t.Fatalf("flushes=%d, want 1", dst.flushes)
+ }
+}
+
+func TestEncoderReturnsShortWrite(t *testing.T) {
+ t.Parallel()
+
+ dst := &limitWriter{shortWrite: true}
+ enc := sideband64k.NewEncoder(dst)
+
+ err := enc.WriteData([]byte("x"))
+ if err != io.ErrShortWrite {
+ t.Fatalf("got err %v, want io.ErrShortWrite", err)
+ }
+}
diff --git a/format/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go b/format/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go
new file mode 100644
index 00000000..aa0adcf0
--- /dev/null
+++ b/format/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go
@@ -0,0 +1,23 @@
+package sideband64k_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestEncoderSetMaxDataCannotExceedWireLimit(t *testing.T) {
+ t.Parallel()
+
+ var dst limitWriter
+
+ enc := sideband64k.NewEncoder(&dst)
+ enc.SetMaxData(sideband64k.DataMax + 100)
+
+ err := enc.WriteData(bytes.Repeat([]byte{'x'}, sideband64k.DataMax+1))
+ if !errors.Is(err, sideband64k.ErrTooLarge) {
+ t.Fatalf("got err %v, want ErrTooLarge", err)
+ }
+}
diff --git a/format/sideband64k/encoder_writes_frames_test.go b/format/sideband64k/encoder_writes_frames_test.go
new file mode 100644
index 00000000..f08c4e43
--- /dev/null
+++ b/format/sideband64k/encoder_writes_frames_test.go
@@ -0,0 +1,45 @@
+package sideband64k_test
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+)
+
+func TestEncoderWritesFrames(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+
+ bw := bufio.NewWriter(&b)
+ enc := sideband64k.NewEncoder(bw)
+
+ if err := enc.WriteData([]byte("hi")); err != nil {
+ t.Fatalf("WriteData: %v", err)
+ }
+ if err := enc.WriteProgress([]byte("ok")); err != nil {
+ t.Fatalf("WriteProgress: %v", err)
+ }
+ if err := enc.WriteError([]byte("no")); err != nil {
+ t.Fatalf("WriteError: %v", err)
+ }
+ if err := enc.WriteFlush(); err != nil {
+ t.Fatalf("WriteFlush: %v", err)
+ }
+ if err := enc.WriteDelim(); err != nil {
+ t.Fatalf("WriteDelim: %v", err)
+ }
+ if err := enc.WriteResponseEnd(); err != nil {
+ t.Fatalf("WriteResponseEnd: %v", err)
+ }
+ if err := enc.FlushIO(); err != nil {
+ t.Fatalf("FlushIO: %v", err)
+ }
+
+ want := "0007\x01hi0007\x02ok0007\x03no000000010002"
+ if got := b.String(); got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/format/sideband64k/errors.go b/format/sideband64k/errors.go
new file mode 100644
index 00000000..c04d7caa
--- /dev/null
+++ b/format/sideband64k/errors.go
@@ -0,0 +1,30 @@
+package sideband64k
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ // ErrTooLarge indicates a payload exceeds configured sideband data limits.
+ ErrTooLarge = errors.New("sideband64k: payload too large")
+ // ErrInvalidBand indicates a data frame has an invalid sideband designator.
+ ErrInvalidBand = errors.New("sideband64k: invalid band designator")
+)
+
+// ProtocolError reports invalid side-band-64k framing.
+type ProtocolError struct {
+ Reason string
+}
+
+func (e *ProtocolError) Error() string {
+ if e == nil {
+ return "<nil>"
+ }
+
+ if e.Reason == "" {
+ return "sideband64k: protocol error"
+ }
+
+ return fmt.Sprintf("sideband64k: protocol error: %s", e.Reason)
+}
diff --git a/format/sideband64k/frame.go b/format/sideband64k/frame.go
new file mode 100644
index 00000000..1335a8e3
--- /dev/null
+++ b/format/sideband64k/frame.go
@@ -0,0 +1,12 @@
+package sideband64k
+
+// Frame is one decoded side-band-64k frame.
+//
+// For FrameData, FrameProgress, and FrameError, Payload holds frame bytes and
+// may be empty.
+//
+// For control frames, Payload is nil.
+type Frame struct {
+ Type FrameType
+ Payload []byte
+}
diff --git a/format/sideband64k/frame_type.go b/format/sideband64k/frame_type.go
new file mode 100644
index 00000000..052d8b10
--- /dev/null
+++ b/format/sideband64k/frame_type.go
@@ -0,0 +1,19 @@
+package sideband64k
+
+// FrameType identifies the kind of decoded sideband frame.
+type FrameType uint8
+
+const (
+ // FrameData carries primary payload bytes from band 1.
+ FrameData FrameType = iota
+ // FrameProgress carries progress bytes from band 2.
+ FrameProgress
+ // FrameError carries fatal error bytes from band 3.
+ FrameError
+ // FrameFlush is pkt-line control frame 0000.
+ FrameFlush
+ // FrameDelim is pkt-line control frame 0001.
+ FrameDelim
+ // FrameResponseEnd is pkt-line control frame 0002.
+ FrameResponseEnd
+)
diff --git a/format/sideband64k/helpers_test.go b/format/sideband64k/helpers_test.go
new file mode 100644
index 00000000..c10e9e71
--- /dev/null
+++ b/format/sideband64k/helpers_test.go
@@ -0,0 +1,44 @@
+package sideband64k_test
+
+import (
+ "bytes"
+ "io"
+)
+
+type limitWriter struct {
+ buf bytes.Buffer
+ maxPerWrite int
+ flushes int
+ shortWrite bool
+}
+
+func (w *limitWriter) Write(p []byte) (int, error) {
+ if w.shortWrite {
+ return 0, nil
+ }
+
+ if w.maxPerWrite > 0 && len(p) > w.maxPerWrite {
+ p = p[:w.maxPerWrite]
+ }
+
+ return w.buf.Write(p)
+}
+
+func (w *limitWriter) Flush() error {
+ w.flushes++
+ return nil
+}
+
+type byteReader struct {
+ data []byte
+}
+
+func (r *byteReader) Read(p []byte) (int, error) {
+ if len(r.data) == 0 {
+ return 0, io.EOF
+ }
+
+ p[0] = r.data[0]
+ r.data = r.data[1:]
+ return 1, nil
+}