diff options
Diffstat (limited to 'network/protocol/sideband64k')
27 files changed, 1124 insertions, 0 deletions
diff --git a/network/protocol/sideband64k/append.go b/network/protocol/sideband64k/append.go new file mode 100644 index 00000000..db6527f8 --- /dev/null +++ b/network/protocol/sideband64k/append.go @@ -0,0 +1,40 @@ +package sideband64k + +import ( + "fmt" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/append_helpers_test.go b/network/protocol/sideband64k/append_helpers_test.go new file mode 100644 index 00000000..03196c38 --- /dev/null +++ b/network/protocol/sideband64k/append_helpers_test.go @@ -0,0 +1,30 @@ +package sideband64k_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/append_preserves_dst_on_error_test.go b/network/protocol/sideband64k/append_preserves_dst_on_error_test.go new file mode 100644 index 00000000..6fed4e4a --- /dev/null +++ b/network/protocol/sideband64k/append_preserves_dst_on_error_test.go @@ -0,0 +1,34 @@ +package sideband64k_test + +import ( + "bytes" + "errors" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/band.go b/network/protocol/sideband64k/band.go new file mode 100644 index 00000000..73c61fd8 --- /dev/null +++ b/network/protocol/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/network/protocol/sideband64k/chunk_writer.go b/network/protocol/sideband64k/chunk_writer.go new file mode 100644 index 00000000..f95f75d8 --- /dev/null +++ b/network/protocol/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/network/protocol/sideband64k/chunk_writer_write_and_read_from_test.go b/network/protocol/sideband64k/chunk_writer_write_and_read_from_test.go new file mode 100644 index 00000000..efde68b4 --- /dev/null +++ b/network/protocol/sideband64k/chunk_writer_write_and_read_from_test.go @@ -0,0 +1,60 @@ +package sideband64k_test + +import ( + "bufio" + "bytes" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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) + } + + err = enc.FlushIO() + if 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) + } + + err = enc.FlushIO() + if 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/network/protocol/sideband64k/constants.go b/network/protocol/sideband64k/constants.go new file mode 100644 index 00000000..2a6a2e47 --- /dev/null +++ b/network/protocol/sideband64k/constants.go @@ -0,0 +1,10 @@ +package sideband64k + +import "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/decoder.go b/network/protocol/sideband64k/decoder.go new file mode 100644 index 00000000..5b47ea87 --- /dev/null +++ b/network/protocol/sideband64k/decoder.go @@ -0,0 +1,158 @@ +package sideband64k + +import ( + "fmt" + "io" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/decoder_data_control_and_keepalive_test.go b/network/protocol/sideband64k/decoder_data_control_and_keepalive_test.go new file mode 100644 index 00000000..9103c492 --- /dev/null +++ b/network/protocol/sideband64k/decoder_data_control_and_keepalive_test.go @@ -0,0 +1,78 @@ +package sideband64k_test + +import ( + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/decoder_invalid_band_test.go b/network/protocol/sideband64k/decoder_invalid_band_test.go new file mode 100644 index 00000000..a4bc11a9 --- /dev/null +++ b/network/protocol/sideband64k/decoder_invalid_band_test.go @@ -0,0 +1,20 @@ +package sideband64k_test + +import ( + "errors" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestDecoderInvalidBand(t *testing.T) { + t.Parallel() + + dec := sideband64k.NewDecoder(strings.NewReader("0005\x04"), sideband64k.ReadOptions{}) + _, err := dec.ReadFrame() + + if _, ok := errors.AsType[*sideband64k.ProtocolError](err); !ok { + t.Fatalf("got err %v, want ProtocolError", err) + } +} diff --git a/network/protocol/sideband64k/decoder_invalid_empty_payload_test.go b/network/protocol/sideband64k/decoder_invalid_empty_payload_test.go new file mode 100644 index 00000000..df9faa71 --- /dev/null +++ b/network/protocol/sideband64k/decoder_invalid_empty_payload_test.go @@ -0,0 +1,20 @@ +package sideband64k_test + +import ( + "errors" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestDecoderInvalidEmptyPayload(t *testing.T) { + t.Parallel() + + dec := sideband64k.NewDecoder(strings.NewReader("0004"), sideband64k.ReadOptions{}) + _, err := dec.ReadFrame() + + if _, ok := errors.AsType[*sideband64k.ProtocolError](err); !ok { + t.Fatalf("got err %v, want ProtocolError", err) + } +} diff --git a/network/protocol/sideband64k/decoder_malformed_pktline_test.go b/network/protocol/sideband64k/decoder_malformed_pktline_test.go new file mode 100644 index 00000000..5e4e4551 --- /dev/null +++ b/network/protocol/sideband64k/decoder_malformed_pktline_test.go @@ -0,0 +1,32 @@ +package sideband64k_test + +import ( + "errors" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/pktline" + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestDecoderInvalid0003(t *testing.T) { + t.Parallel() + + dec := sideband64k.NewDecoder(strings.NewReader("0003"), sideband64k.ReadOptions{}) + _, err := dec.ReadFrame() + + if _, ok := errors.AsType[*pktline.ProtocolError](err); !ok { + 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() + + if _, ok := errors.AsType[*pktline.ProtocolError](err); !ok { + t.Fatalf("got err %v, want pktline.ProtocolError", err) + } +} diff --git a/network/protocol/sideband64k/decoder_partial_read_test.go b/network/protocol/sideband64k/decoder_partial_read_test.go new file mode 100644 index 00000000..3f103787 --- /dev/null +++ b/network/protocol/sideband64k/decoder_partial_read_test.go @@ -0,0 +1,32 @@ +package sideband64k_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/decoder_peek_test.go b/network/protocol/sideband64k/decoder_peek_test.go new file mode 100644 index 00000000..31397762 --- /dev/null +++ b/network/protocol/sideband64k/decoder_peek_test.go @@ -0,0 +1,34 @@ +package sideband64k_test + +import ( + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/decoder_resync_after_over_max_data_test.go b/network/protocol/sideband64k/decoder_resync_after_over_max_data_test.go new file mode 100644 index 00000000..c63b07f3 --- /dev/null +++ b/network/protocol/sideband64k/decoder_resync_after_over_max_data_test.go @@ -0,0 +1,51 @@ +package sideband64k_test + +import ( + "bufio" + "bytes" + "errors" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestDecoderResyncAfterOverMaxData(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + + bw := bufio.NewWriter(&b) + enc := sideband64k.NewEncoder(bw) + + err := enc.WriteData([]byte("abcd")) + if err != nil { + t.Fatalf("WriteData #1: %v", err) + } + + err = enc.WriteData([]byte("z")) + if err != nil { + t.Fatalf("WriteData #2: %v", err) + } + + err = enc.FlushIO() + if 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/network/protocol/sideband64k/decoder_resync_after_over_wire_max_test.go b/network/protocol/sideband64k/decoder_resync_after_over_wire_max_test.go new file mode 100644 index 00000000..73966925 --- /dev/null +++ b/network/protocol/sideband64k/decoder_resync_after_over_wire_max_test.go @@ -0,0 +1,37 @@ +package sideband64k_test + +import ( + "bytes" + "errors" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/pktline" + "codeberg.org/lindenii/furgit/network/protocol/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() + + if _, ok := errors.AsType[*pktline.ProtocolError](err); !ok { + 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/network/protocol/sideband64k/decoder_unexpected_eof_test.go b/network/protocol/sideband64k/decoder_unexpected_eof_test.go new file mode 100644 index 00000000..d9d71fb9 --- /dev/null +++ b/network/protocol/sideband64k/decoder_unexpected_eof_test.go @@ -0,0 +1,21 @@ +package sideband64k_test + +import ( + "errors" + "io" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/doc.go b/network/protocol/sideband64k/doc.go new file mode 100644 index 00000000..55c33650 --- /dev/null +++ b/network/protocol/sideband64k/doc.go @@ -0,0 +1,2 @@ +// Package sideband64k implements Git side-band-64k multiplexing over pkt-line. +package sideband64k diff --git a/network/protocol/sideband64k/encoder.go b/network/protocol/sideband64k/encoder.go new file mode 100644 index 00000000..9f729d15 --- /dev/null +++ b/network/protocol/sideband64k/encoder.go @@ -0,0 +1,98 @@ +package sideband64k + +import ( + "fmt" + + "codeberg.org/lindenii/furgit/network/protocol/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/network/protocol/sideband64k/encoder_buffered_flush_behavior_test.go b/network/protocol/sideband64k/encoder_buffered_flush_behavior_test.go new file mode 100644 index 00000000..29f87d01 --- /dev/null +++ b/network/protocol/sideband64k/encoder_buffered_flush_behavior_test.go @@ -0,0 +1,59 @@ +package sideband64k_test + +import ( + "bufio" + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestEncoderBufferedFlushBehavior(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + bw := bufio.NewWriter(&out) + enc := sideband64k.NewEncoder(bw) + + err := enc.WriteData([]byte("hello")) + if err != nil { + t.Fatalf("WriteData: %v", err) + } + + err = enc.WriteFlush() + if err != nil { + t.Fatalf("WriteFlush: %v", err) + } + + if out.Len() != 0 { + t.Fatalf("WriteFlush should not flush I/O, got %q", out.String()) + } + + err = enc.FlushIO() + if 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) + + err = enc.WriteData([]byte("yo")) + if err != nil { + t.Fatalf("WriteData: %v", err) + } + + err = enc.WriteFlushAndFlushIO() + if 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/network/protocol/sideband64k/encoder_partial_write_test.go b/network/protocol/sideband64k/encoder_partial_write_test.go new file mode 100644 index 00000000..8e35376a --- /dev/null +++ b/network/protocol/sideband64k/encoder_partial_write_test.go @@ -0,0 +1,46 @@ +package sideband64k_test + +import ( + "errors" + "io" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestEncoderHandlesPartialWrites(t *testing.T) { + t.Parallel() + + dst := &limitWriter{maxPerWrite: 2} + enc := sideband64k.NewEncoder(dst) + + err := enc.WriteProgress([]byte("abc")) + if err != nil { + t.Fatalf("WriteProgress: %v", err) + } + + err = enc.WriteFlushAndFlushIO() + if 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 !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("got err %v, want io.ErrShortWrite", err) + } +} diff --git a/network/protocol/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go b/network/protocol/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go new file mode 100644 index 00000000..2bfcf073 --- /dev/null +++ b/network/protocol/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/network/protocol/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/network/protocol/sideband64k/encoder_writes_frames_test.go b/network/protocol/sideband64k/encoder_writes_frames_test.go new file mode 100644 index 00000000..4541bf13 --- /dev/null +++ b/network/protocol/sideband64k/encoder_writes_frames_test.go @@ -0,0 +1,58 @@ +package sideband64k_test + +import ( + "bufio" + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" +) + +func TestEncoderWritesFrames(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + + bw := bufio.NewWriter(&b) + enc := sideband64k.NewEncoder(bw) + + err := enc.WriteData([]byte("hi")) + if err != nil { + t.Fatalf("WriteData: %v", err) + } + + err = enc.WriteProgress([]byte("ok")) + if err != nil { + t.Fatalf("WriteProgress: %v", err) + } + + err = enc.WriteError([]byte("no")) + if err != nil { + t.Fatalf("WriteError: %v", err) + } + + err = enc.WriteFlush() + if err != nil { + t.Fatalf("WriteFlush: %v", err) + } + + err = enc.WriteDelim() + if err != nil { + t.Fatalf("WriteDelim: %v", err) + } + + err = enc.WriteResponseEnd() + if err != nil { + t.Fatalf("WriteResponseEnd: %v", err) + } + + err = enc.FlushIO() + if 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/network/protocol/sideband64k/errors.go b/network/protocol/sideband64k/errors.go new file mode 100644 index 00000000..44e7c165 --- /dev/null +++ b/network/protocol/sideband64k/errors.go @@ -0,0 +1,27 @@ +package sideband64k + +import "errors" + +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 "sideband64k: protocol error: " + e.Reason +} diff --git a/network/protocol/sideband64k/frame.go b/network/protocol/sideband64k/frame.go new file mode 100644 index 00000000..1335a8e3 --- /dev/null +++ b/network/protocol/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/network/protocol/sideband64k/frame_type.go b/network/protocol/sideband64k/frame_type.go new file mode 100644 index 00000000..052d8b10 --- /dev/null +++ b/network/protocol/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/network/protocol/sideband64k/helpers_test.go b/network/protocol/sideband64k/helpers_test.go new file mode 100644 index 00000000..f9b2608f --- /dev/null +++ b/network/protocol/sideband64k/helpers_test.go @@ -0,0 +1,46 @@ +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 +} |
