aboutsummaryrefslogtreecommitdiff
path: root/network
diff options
context:
space:
mode:
Diffstat (limited to 'network')
-rw-r--r--network/protocol/doc.go2
-rw-r--r--network/protocol/pktline/append.go39
-rw-r--r--network/protocol/pktline/append_data_preserves_dst_on_error_test.go25
-rw-r--r--network/protocol/pktline/append_helpers_test.go24
-rw-r--r--network/protocol/pktline/chunk_writer.go65
-rw-r--r--network/protocol/pktline/chunk_writer_write_and_read_from_test.go60
-rw-r--r--network/protocol/pktline/constants.go12
-rw-r--r--network/protocol/pktline/decoder.go187
-rw-r--r--network/protocol/pktline/decoder_data_control_and_0004_test.go60
-rw-r--r--network/protocol/pktline/decoder_invalid_0003_test.go20
-rw-r--r--network/protocol/pktline/decoder_peek_test.go32
-rw-r--r--network/protocol/pktline/decoder_rejects_over_maximum_length_test.go22
-rw-r--r--network/protocol/pktline/decoder_resync_after_over_max_data_test.go51
-rw-r--r--network/protocol/pktline/decoder_resync_after_over_wire_max_test.go37
-rw-r--r--network/protocol/pktline/decoder_unexpected_eof_test.go21
-rw-r--r--network/protocol/pktline/doc.go2
-rw-r--r--network/protocol/pktline/encode_length_header_test.go28
-rw-r--r--network/protocol/pktline/encoder.go145
-rw-r--r--network/protocol/pktline/encoder_buffered_flush_and_f_flush_test.go50
-rw-r--r--network/protocol/pktline/encoder_buffered_flush_behavior_test.go86
-rw-r--r--network/protocol/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go26
-rw-r--r--network/protocol/pktline/encoder_writes_frames_test.go51
-rw-r--r--network/protocol/pktline/errors.go31
-rw-r--r--network/protocol/pktline/frame.go10
-rw-r--r--network/protocol/pktline/header.go57
-rw-r--r--network/protocol/pktline/parse_length_header_test.go26
-rw-r--r--network/protocol/pktline/type.go15
-rw-r--r--network/protocol/sideband64k/append.go40
-rw-r--r--network/protocol/sideband64k/append_helpers_test.go30
-rw-r--r--network/protocol/sideband64k/append_preserves_dst_on_error_test.go34
-rw-r--r--network/protocol/sideband64k/band.go13
-rw-r--r--network/protocol/sideband64k/chunk_writer.go64
-rw-r--r--network/protocol/sideband64k/chunk_writer_write_and_read_from_test.go60
-rw-r--r--network/protocol/sideband64k/constants.go10
-rw-r--r--network/protocol/sideband64k/decoder.go158
-rw-r--r--network/protocol/sideband64k/decoder_data_control_and_keepalive_test.go78
-rw-r--r--network/protocol/sideband64k/decoder_invalid_band_test.go20
-rw-r--r--network/protocol/sideband64k/decoder_invalid_empty_payload_test.go20
-rw-r--r--network/protocol/sideband64k/decoder_malformed_pktline_test.go32
-rw-r--r--network/protocol/sideband64k/decoder_partial_read_test.go32
-rw-r--r--network/protocol/sideband64k/decoder_peek_test.go34
-rw-r--r--network/protocol/sideband64k/decoder_resync_after_over_max_data_test.go51
-rw-r--r--network/protocol/sideband64k/decoder_resync_after_over_wire_max_test.go37
-rw-r--r--network/protocol/sideband64k/decoder_unexpected_eof_test.go21
-rw-r--r--network/protocol/sideband64k/doc.go2
-rw-r--r--network/protocol/sideband64k/encoder.go98
-rw-r--r--network/protocol/sideband64k/encoder_buffered_flush_behavior_test.go59
-rw-r--r--network/protocol/sideband64k/encoder_partial_write_test.go46
-rw-r--r--network/protocol/sideband64k/encoder_set_max_data_cannot_exceed_wire_limit_test.go23
-rw-r--r--network/protocol/sideband64k/encoder_writes_frames_test.go58
-rw-r--r--network/protocol/sideband64k/errors.go27
-rw-r--r--network/protocol/sideband64k/frame.go12
-rw-r--r--network/protocol/sideband64k/frame_type.go19
-rw-r--r--network/protocol/sideband64k/helpers_test.go46
-rw-r--r--network/protocol/v0v1/doc.go2
-rw-r--r--network/protocol/v0v1/server/advertise.go55
-rw-r--r--network/protocol/v0v1/server/advertise_test.go101
-rw-r--r--network/protocol/v0v1/server/advertised_ref.go22
-rw-r--r--network/protocol/v0v1/server/doc.go2
-rw-r--r--network/protocol/v0v1/server/errors.go18
-rw-r--r--network/protocol/v0v1/server/frame.go20
-rw-r--r--network/protocol/v0v1/server/helpers.go29
-rw-r--r--network/protocol/v0v1/server/helpers_test.go28
-rw-r--r--network/protocol/v0v1/server/receivepack/capabilities.go192
-rw-r--r--network/protocol/v0v1/server/receivepack/doc.go2
-rw-r--r--network/protocol/v0v1/server/receivepack/errors.go11
-rw-r--r--network/protocol/v0v1/server/receivepack/helpers_test.go28
-rw-r--r--network/protocol/v0v1/server/receivepack/parse_test.go255
-rw-r--r--network/protocol/v0v1/server/receivepack/report_status.go185
-rw-r--r--network/protocol/v0v1/server/receivepack/report_status_test.go293
-rw-r--r--network/protocol/v0v1/server/receivepack/session.go295
-rw-r--r--network/protocol/v0v1/server/receivepack/types.go45
-rw-r--r--network/protocol/v0v1/server/session.go131
-rw-r--r--network/protocol/v0v1/server/version.go12
74 files changed, 4034 insertions, 0 deletions
diff --git a/network/protocol/doc.go b/network/protocol/doc.go
new file mode 100644
index 00000000..d1e00447
--- /dev/null
+++ b/network/protocol/doc.go
@@ -0,0 +1,2 @@
+// Package protocol encapsulates network protocol implementations.
+package protocol
diff --git a/network/protocol/pktline/append.go b/network/protocol/pktline/append.go
new file mode 100644
index 00000000..9425e58e
--- /dev/null
+++ b/network/protocol/pktline/append.go
@@ -0,0 +1,39 @@
+package pktline
+
+import "fmt"
+
+// AppendData appends one data frame to dst.
+//
+// Empty payload is encoded as 0004.
+func AppendData(dst, payload []byte) ([]byte, error) {
+ if len(payload) > LargePacketDataMax {
+ return dst, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(payload), LargePacketDataMax)
+ }
+
+ var hdr [4]byte
+
+ err := EncodeLengthHeader(&hdr, len(payload)+4)
+ if err != nil {
+ return dst, err
+ }
+
+ dst = append(dst, hdr[:]...)
+ dst = append(dst, payload...)
+
+ return dst, nil
+}
+
+// AppendFlushPkt appends control frame 0000 (flush-pkt).
+func AppendFlushPkt(dst []byte) []byte {
+ return append(dst, '0', '0', '0', '0')
+}
+
+// AppendDelimPkt appends control frame 0001 (delim-pkt).
+func AppendDelimPkt(dst []byte) []byte {
+ return append(dst, '0', '0', '0', '1')
+}
+
+// AppendResponseEndPkt appends control frame 0002 (response-end-pkt).
+func AppendResponseEndPkt(dst []byte) []byte {
+ return append(dst, '0', '0', '0', '2')
+}
diff --git a/network/protocol/pktline/append_data_preserves_dst_on_error_test.go b/network/protocol/pktline/append_data_preserves_dst_on_error_test.go
new file mode 100644
index 00000000..d127fb39
--- /dev/null
+++ b/network/protocol/pktline/append_data_preserves_dst_on_error_test.go
@@ -0,0 +1,25 @@
+package pktline_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestAppendDataPreservesDstOnError(t *testing.T) {
+ t.Parallel()
+
+ orig := []byte("seed")
+ dst := append([]byte(nil), orig...)
+
+ out, err := pktline.AppendData(dst, bytes.Repeat([]byte{'x'}, pktline.LargePacketDataMax+1))
+ if !errors.Is(err, pktline.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/pktline/append_helpers_test.go b/network/protocol/pktline/append_helpers_test.go
new file mode 100644
index 00000000..259ada16
--- /dev/null
+++ b/network/protocol/pktline/append_helpers_test.go
@@ -0,0 +1,24 @@
+package pktline_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestAppendHelpers(t *testing.T) {
+ t.Parallel()
+
+ out, err := pktline.AppendData(nil, []byte("ok"))
+ if err != nil {
+ t.Fatalf("AppendData: %v", err)
+ }
+
+ out = pktline.AppendFlushPkt(out)
+ out = pktline.AppendDelimPkt(out)
+ out = pktline.AppendResponseEndPkt(out)
+
+ if got, want := string(out), "0006ok000000010002"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/network/protocol/pktline/chunk_writer.go b/network/protocol/pktline/chunk_writer.go
new file mode 100644
index 00000000..b258ff20
--- /dev/null
+++ b/network/protocol/pktline/chunk_writer.go
@@ -0,0 +1,65 @@
+package pktline
+
+import "io"
+
+// ChunkWriter packetizes arbitrary stream bytes into data pkt-lines.
+// It never writes control packets automatically.
+type ChunkWriter struct {
+ enc *Encoder
+}
+
+// NewChunkWriter creates a chunking adapter over enc.
+func NewChunkWriter(enc *Encoder) *ChunkWriter {
+ return &ChunkWriter{enc: enc}
+}
+
+// Write splits p into data frames not larger than enc's maxData.
+//
+// It implements io.Writer.
+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.WriteData(p[:n])
+ if err != nil {
+ return total, err
+ }
+
+ total += n
+ p = p[n:]
+ }
+
+ return total, nil
+}
+
+// ReadFrom reads from r and writes pkt-line data frames to the encoder.
+//
+// It implements io.ReaderFrom.
+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.WriteData(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/pktline/chunk_writer_write_and_read_from_test.go b/network/protocol/pktline/chunk_writer_write_and_read_from_test.go
new file mode 100644
index 00000000..c3e2bafb
--- /dev/null
+++ b/network/protocol/pktline/chunk_writer_write_and_read_from_test.go
@@ -0,0 +1,60 @@
+package pktline_test
+
+import (
+ "bufio"
+ "bytes"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestChunkWriterWriteAndReadFrom(t *testing.T) {
+ t.Parallel()
+
+ var out bytes.Buffer
+
+ bw := bufio.NewWriter(&out)
+
+ enc := pktline.NewEncoder(bw)
+ enc.SetMaxData(3)
+ cw := pktline.NewChunkWriter(enc)
+
+ 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(), "0007abc0007def0005g"; 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(), "0007wxy0005z"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/network/protocol/pktline/constants.go b/network/protocol/pktline/constants.go
new file mode 100644
index 00000000..811eb3c6
--- /dev/null
+++ b/network/protocol/pktline/constants.go
@@ -0,0 +1,12 @@
+package pktline
+
+const (
+ // DefaultPacketMax is a conservative packet size commonly used by
+ // line-oriented protocol messages.
+ DefaultPacketMax = 1000
+ // LargePacketMax is the maximum on-wire packet size including the
+ // 4-byte hexadecimal length header.
+ LargePacketMax = 65520
+ // LargePacketDataMax is the maximum payload size in one packet.
+ LargePacketDataMax = LargePacketMax - 4
+)
diff --git a/network/protocol/pktline/decoder.go b/network/protocol/pktline/decoder.go
new file mode 100644
index 00000000..898d8ad6
--- /dev/null
+++ b/network/protocol/pktline/decoder.go
@@ -0,0 +1,187 @@
+package pktline
+
+import (
+ "errors"
+ "fmt"
+ "io"
+)
+
+// ReadOptions controls decoding behavior.
+type ReadOptions struct {
+ // ChompLF removes one trailing '\n' from PacketData payloads.
+ ChompLF bool
+}
+
+// Decoder reads pkt-line frames from an io.Reader.
+//
+// It is advisable to supply a buffered reader.
+//
+// It preserves frame boundaries and supports one-frame lookahead via PeekFrame.
+type Decoder struct {
+ r io.Reader
+ maxData int
+ opts ReadOptions
+
+ peeked bool
+ peek Frame
+ peekErr error
+}
+
+// NewDecoder creates a decoder over r.
+func NewDecoder(r io.Reader, opts ReadOptions) *Decoder {
+ return &Decoder{
+ r: r,
+ maxData: LargePacketDataMax,
+ opts: opts,
+ }
+}
+
+// SetMaxData sets maximum payload size accepted for one data packet.
+//
+// Non-positive n resets to LargePacketDataMax.
+func (d *Decoder) SetMaxData(n int) {
+ if n <= 0 {
+ d.maxData = LargePacketDataMax
+
+ return
+ }
+
+ d.maxData = n
+}
+
+func cloneFrame(f Frame) Frame {
+ if f.Type != PacketData {
+ return Frame{Type: f.Type}
+ }
+
+ out := Frame{Type: f.Type}
+ if f.Payload != nil {
+ out.Payload = append([]byte(nil), f.Payload...)
+ }
+
+ return out
+}
+
+// ReadFrame reads one frame.
+//
+// 0000 is a PacketFlush
+// 0001 is a PacketDelim
+// 0002 is a PacketResponseEnd
+// 0004 is a PacketData with empty payload
+//
+// 0003 and malformed headers return *ProtocolError.
+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.
+//
+// A subsequent ReadFrame returns the same frame.
+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) {
+ var hdr [4]byte
+
+ _, err := io.ReadFull(d.r, hdr[:])
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return Frame{}, io.EOF
+ }
+
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return Frame{}, io.ErrUnexpectedEOF
+ }
+
+ return Frame{}, err
+ }
+
+ n, err := ParseLengthHeader(hdr)
+ if err != nil {
+ return Frame{}, &ProtocolError{Header: hdr, Reason: err.Error()}
+ }
+
+ switch n {
+ case 0:
+ return Frame{Type: PacketFlush}, nil
+ case 1:
+ return Frame{Type: PacketDelim}, nil
+ case 2:
+ return Frame{Type: PacketResponseEnd}, nil
+ case 3:
+ return Frame{}, &ProtocolError{Header: hdr, Reason: "invalid pkt-line length 3"}
+ }
+
+ if n < 4 {
+ return Frame{}, &ProtocolError{Header: hdr, Reason: fmt.Sprintf("invalid pkt-line length %d", n)}
+ }
+
+ if n > LargePacketMax {
+ perr := &ProtocolError{Header: hdr, Reason: fmt.Sprintf("pkt-line length %d exceeds max %d", n, LargePacketMax)}
+
+ err := d.discardPayload(n - 4)
+ if err != nil {
+ return Frame{}, errors.Join(perr, err)
+ }
+
+ return Frame{}, perr
+ }
+
+ payloadLen := n - 4
+ if payloadLen > d.maxData {
+ serr := fmt.Errorf("%w: %d > %d", ErrTooLarge, payloadLen, d.maxData)
+
+ err := d.discardPayload(payloadLen)
+ if err != nil {
+ return Frame{}, errors.Join(serr, err)
+ }
+
+ return Frame{}, serr
+ }
+
+ payload := make([]byte, payloadLen)
+
+ _, err = io.ReadFull(d.r, payload)
+ if err != nil {
+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
+ return Frame{}, io.ErrUnexpectedEOF
+ }
+
+ return Frame{}, err
+ }
+
+ if d.opts.ChompLF && len(payload) > 0 && payload[len(payload)-1] == '\n' {
+ payload = payload[:len(payload)-1]
+ }
+
+ return Frame{Type: PacketData, Payload: payload}, nil
+}
+
+func (d *Decoder) discardPayload(n int) error {
+ if n <= 0 {
+ return nil
+ }
+
+ _, err := io.CopyN(io.Discard, d.r, int64(n))
+ if err == nil {
+ return nil
+ }
+
+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
+ return io.ErrUnexpectedEOF
+ }
+
+ return err
+}
diff --git a/network/protocol/pktline/decoder_data_control_and_0004_test.go b/network/protocol/pktline/decoder_data_control_and_0004_test.go
new file mode 100644
index 00000000..ab92b603
--- /dev/null
+++ b/network/protocol/pktline/decoder_data_control_and_0004_test.go
@@ -0,0 +1,60 @@
+package pktline_test
+
+import (
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderDataControlAnd0004(t *testing.T) {
+ t.Parallel()
+
+ input := "0006a\n0004000100020000"
+ dec := pktline.NewDecoder(strings.NewReader(input), pktline.ReadOptions{ChompLF: true})
+
+ f, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #1: %v", err)
+ }
+
+ if f.Type != pktline.PacketData || 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 != pktline.PacketData || 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 != pktline.PacketDelim {
+ t.Fatalf("frame #3 type = %v, want PacketDelim", f.Type)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #4: %v", err)
+ }
+
+ if f.Type != pktline.PacketResponseEnd {
+ t.Fatalf("frame #4 type = %v, want PacketResponseEnd", f.Type)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #5: %v", err)
+ }
+
+ if f.Type != pktline.PacketFlush {
+ t.Fatalf("frame #5 type = %v, want PacketFlush", f.Type)
+ }
+}
diff --git a/network/protocol/pktline/decoder_invalid_0003_test.go b/network/protocol/pktline/decoder_invalid_0003_test.go
new file mode 100644
index 00000000..716da3f2
--- /dev/null
+++ b/network/protocol/pktline/decoder_invalid_0003_test.go
@@ -0,0 +1,20 @@
+package pktline_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderInvalid0003(t *testing.T) {
+ t.Parallel()
+
+ dec := pktline.NewDecoder(strings.NewReader("0003"), pktline.ReadOptions{})
+ _, err := dec.ReadFrame()
+
+ if _, ok := errors.AsType[*pktline.ProtocolError](err); !ok {
+ t.Fatalf("got err %v, want ProtocolError", err)
+ }
+}
diff --git a/network/protocol/pktline/decoder_peek_test.go b/network/protocol/pktline/decoder_peek_test.go
new file mode 100644
index 00000000..a67da881
--- /dev/null
+++ b/network/protocol/pktline/decoder_peek_test.go
@@ -0,0 +1,32 @@
+package pktline_test
+
+import (
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderPeek(t *testing.T) {
+ t.Parallel()
+
+ dec := pktline.NewDecoder(strings.NewReader("0005x0000"), pktline.ReadOptions{})
+
+ f, err := dec.PeekFrame()
+ if err != nil {
+ t.Fatalf("PeekFrame: %v", err)
+ }
+
+ if f.Type != pktline.PacketData || string(f.Payload) != "x" {
+ t.Fatalf("peek frame = %#v", f)
+ }
+
+ f, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame: %v", err)
+ }
+
+ if f.Type != pktline.PacketData || string(f.Payload) != "x" {
+ t.Fatalf("read frame = %#v", f)
+ }
+}
diff --git a/network/protocol/pktline/decoder_rejects_over_maximum_length_test.go b/network/protocol/pktline/decoder_rejects_over_maximum_length_test.go
new file mode 100644
index 00000000..357bfc36
--- /dev/null
+++ b/network/protocol/pktline/decoder_rejects_over_maximum_length_test.go
@@ -0,0 +1,22 @@
+package pktline_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderRejectsOverMaximumLength(t *testing.T) {
+ t.Parallel()
+
+ dec := pktline.NewDecoder(strings.NewReader("fffe"), pktline.ReadOptions{})
+ dec.SetMaxData(70000)
+
+ _, err := dec.ReadFrame()
+
+ if _, ok := errors.AsType[*pktline.ProtocolError](err); !ok {
+ t.Fatalf("got err %v, want ProtocolError", err)
+ }
+}
diff --git a/network/protocol/pktline/decoder_resync_after_over_max_data_test.go b/network/protocol/pktline/decoder_resync_after_over_max_data_test.go
new file mode 100644
index 00000000..d4146147
--- /dev/null
+++ b/network/protocol/pktline/decoder_resync_after_over_max_data_test.go
@@ -0,0 +1,51 @@
+package pktline_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderResyncAfterOverMaxData(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+
+ bw := bufio.NewWriter(&b)
+ enc := pktline.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 := pktline.NewDecoder(bytes.NewReader(b.Bytes()), pktline.ReadOptions{})
+ dec.SetMaxData(1)
+
+ _, err = dec.ReadFrame()
+ if !errors.Is(err, pktline.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 != pktline.PacketData || string(f.Payload) != "z" {
+ t.Fatalf("got frame %#v, want data z", f)
+ }
+}
diff --git a/network/protocol/pktline/decoder_resync_after_over_wire_max_test.go b/network/protocol/pktline/decoder_resync_after_over_wire_max_test.go
new file mode 100644
index 00000000..9413823b
--- /dev/null
+++ b/network/protocol/pktline/decoder_resync_after_over_wire_max_test.go
@@ -0,0 +1,37 @@
+package pktline_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderResyncAfterOverWireMax(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+
+ _, _ = b.WriteString("ffff")
+ _, _ = b.Write(bytes.Repeat([]byte{'a'}, 65531))
+ _, _ = b.WriteString("0005z")
+
+ dec := pktline.NewDecoder(bytes.NewReader(b.Bytes()), pktline.ReadOptions{})
+ dec.SetMaxData(70000)
+
+ _, err := dec.ReadFrame()
+
+ if _, ok := errors.AsType[*pktline.ProtocolError](err); !ok {
+ t.Fatalf("got err %v, want ProtocolError", err)
+ }
+
+ f, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame #2: %v", err)
+ }
+
+ if f.Type != pktline.PacketData || string(f.Payload) != "z" {
+ t.Fatalf("got frame %#v, want data z", f)
+ }
+}
diff --git a/network/protocol/pktline/decoder_unexpected_eof_test.go b/network/protocol/pktline/decoder_unexpected_eof_test.go
new file mode 100644
index 00000000..e1bf4457
--- /dev/null
+++ b/network/protocol/pktline/decoder_unexpected_eof_test.go
@@ -0,0 +1,21 @@
+package pktline_test
+
+import (
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestDecoderUnexpectedEOF(t *testing.T) {
+ t.Parallel()
+
+ dec := pktline.NewDecoder(strings.NewReader("0006a"), pktline.ReadOptions{})
+
+ _, err := dec.ReadFrame()
+ if !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("got err %v, want io.ErrUnexpectedEOF", err)
+ }
+}
diff --git a/network/protocol/pktline/doc.go b/network/protocol/pktline/doc.go
new file mode 100644
index 00000000..3f7cca89
--- /dev/null
+++ b/network/protocol/pktline/doc.go
@@ -0,0 +1,2 @@
+// Package pktline implements the pkt-line format specified in gitprotocol-common(5).
+package pktline
diff --git a/network/protocol/pktline/encode_length_header_test.go b/network/protocol/pktline/encode_length_header_test.go
new file mode 100644
index 00000000..38a980f0
--- /dev/null
+++ b/network/protocol/pktline/encode_length_header_test.go
@@ -0,0 +1,28 @@
+package pktline_test
+
+import (
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestEncodeLengthHeader(t *testing.T) {
+ t.Parallel()
+
+ var hdr [4]byte
+
+ err := pktline.EncodeLengthHeader(&hdr, 4)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if got := string(hdr[:]); got != "0004" {
+ t.Fatalf("got %q, want %q", got, "0004")
+ }
+
+ err = pktline.EncodeLengthHeader(&hdr, pktline.LargePacketMax+1)
+ if !errors.Is(err, pktline.ErrInvalidLength) {
+ t.Fatalf("got err %v, want ErrInvalidLength", err)
+ }
+}
diff --git a/network/protocol/pktline/encoder.go b/network/protocol/pktline/encoder.go
new file mode 100644
index 00000000..b4c6dbf0
--- /dev/null
+++ b/network/protocol/pktline/encoder.go
@@ -0,0 +1,145 @@
+package pktline
+
+import (
+ "fmt"
+ "io"
+)
+
+// WriteFlusher is the output transport contract required by Encoder.
+//
+// Write emits framed bytes and Flush pushes buffered transport state.
+type WriteFlusher interface {
+ io.Writer
+ Flush() error
+}
+
+// Encoder writes pkt-line frames to a flush-capable output transport.
+//
+// It writes exactly one frame per method call and does not auto-chunk data.
+type Encoder struct {
+ w WriteFlusher
+ maxData int
+}
+
+// NewEncoder creates an encoder over w.
+func NewEncoder(w WriteFlusher) *Encoder {
+ return &Encoder{
+ w: w,
+ maxData: LargePacketDataMax,
+ }
+}
+
+// SetMaxData sets the maximum payload size accepted by WriteData.
+//
+// Non-positive n resets to LargePacketDataMax.
+func (e *Encoder) SetMaxData(n int) {
+ if n <= 0 {
+ e.maxData = LargePacketDataMax
+
+ return
+ }
+
+ e.maxData = n
+}
+
+func writeAll(w io.Writer, b []byte) error {
+ for len(b) > 0 {
+ n, err := w.Write(b)
+ if err != nil {
+ return err
+ }
+
+ if n <= 0 {
+ return io.ErrShortWrite
+ }
+
+ b = b[n:]
+ }
+
+ return nil
+}
+
+// WriteData writes one data frame.
+//
+// Empty payload is encoded as 0004.
+func (e *Encoder) WriteData(p []byte) error {
+ maxData := e.effectiveMaxData()
+ if len(p) > maxData {
+ return fmt.Errorf("%w: %d > %d", ErrTooLarge, len(p), maxData)
+ }
+
+ var hdr [4]byte
+
+ err := EncodeLengthHeader(&hdr, len(p)+4)
+ if err != nil {
+ return err
+ }
+
+ err = writeAll(e.w, hdr[:])
+ if err != nil {
+ return err
+ }
+
+ return writeAll(e.w, p)
+}
+
+// WriteString writes one data frame containing s and returns len(s) on success.
+func (e *Encoder) WriteString(s string) (int, error) {
+ err := e.WriteData([]byte(s))
+ if err != nil {
+ return 0, err
+ }
+
+ return len(s), nil
+}
+
+// WriteFlush writes control frame 0000 (flush-pkt).
+func (e *Encoder) WriteFlush() error {
+ return e.writeControl(0)
+}
+
+// WriteDelim writes control frame 0001 (delim-pkt).
+func (e *Encoder) WriteDelim() error {
+ return e.writeControl(1)
+}
+
+// WriteResponseEnd writes control frame 0002 (response-end-pkt).
+func (e *Encoder) WriteResponseEnd() error {
+ return e.writeControl(2)
+}
+
+// FlushIO flushes buffered output in the underlying transport.
+//
+// FlushIO does not emit any pkt-line control frame.
+func (e *Encoder) FlushIO() error {
+ return e.w.Flush()
+}
+
+// WriteFlushAndFlushIO writes a flush-pkt (0000) then flushes transport I/O.
+func (e *Encoder) WriteFlushAndFlushIO() error {
+ err := e.WriteFlush()
+ if err != nil {
+ return err
+ }
+
+ return e.FlushIO()
+}
+
+func (e *Encoder) writeControl(n int) error {
+ var hdr [4]byte
+
+ err := EncodeLengthHeader(&hdr, n)
+ if err != nil {
+ return err
+ }
+
+ return writeAll(e.w, hdr[:])
+}
+
+func (e *Encoder) effectiveMaxData() int {
+ if e.maxData <= 0 || e.maxData > LargePacketDataMax {
+ return LargePacketDataMax
+ }
+
+ return e.maxData
+}
diff --git a/network/protocol/pktline/encoder_buffered_flush_and_f_flush_test.go b/network/protocol/pktline/encoder_buffered_flush_and_f_flush_test.go
new file mode 100644
index 00000000..3d13bc7d
--- /dev/null
+++ b/network/protocol/pktline/encoder_buffered_flush_and_f_flush_test.go
@@ -0,0 +1,50 @@
+package pktline_test
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestEncoderBufferedFlushAndFFlush(t *testing.T) {
+ t.Parallel()
+
+ var out bytes.Buffer
+
+ bw := bufio.NewWriter(&out)
+ enc := pktline.NewEncoder(bw)
+
+ err := enc.WriteData([]byte("x"))
+ if err != nil {
+ t.Fatalf("WriteData: %v", err)
+ }
+
+ if out.Len() != 0 {
+ t.Fatalf("unexpected immediate output: %q", out.String())
+ }
+
+ err = enc.FlushIO()
+ if err != nil {
+ t.Fatalf("FlushIO: %v", err)
+ }
+
+ if out.String() != "0005x" {
+ t.Fatalf("got %q, want %q", out.String(), "0005x")
+ }
+
+ out.Reset()
+ bw = bufio.NewWriter(&out)
+
+ enc = pktline.NewEncoder(bw)
+
+ err = enc.WriteFlushAndFlushIO()
+ if err != nil {
+ t.Fatalf("WriteFlushAndFlushIO: %v", err)
+ }
+
+ if out.String() != "0000" {
+ t.Fatalf("got %q, want %q", out.String(), "0000")
+ }
+}
diff --git a/network/protocol/pktline/encoder_buffered_flush_behavior_test.go b/network/protocol/pktline/encoder_buffered_flush_behavior_test.go
new file mode 100644
index 00000000..9daba241
--- /dev/null
+++ b/network/protocol/pktline/encoder_buffered_flush_behavior_test.go
@@ -0,0 +1,86 @@
+package pktline_test
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestEncoderBufferedFlushBehavior(t *testing.T) {
+ t.Parallel()
+
+ var out bytes.Buffer
+
+ bw := bufio.NewWriter(&out)
+ enc := pktline.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(), "0009hello0000"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+
+ out.Reset()
+ bw = bufio.NewWriter(&out)
+ enc = pktline.NewEncoder(bw)
+
+ err = enc.WriteData([]byte("ok"))
+ 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(), "0006ok0000"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+
+ out.Reset()
+ bw = bufio.NewWriter(&out)
+ enc = pktline.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(), "0006yo0000"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/network/protocol/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go b/network/protocol/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go
new file mode 100644
index 00000000..d73baa4f
--- /dev/null
+++ b/network/protocol/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go
@@ -0,0 +1,26 @@
+package pktline_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestEncoderSetMaxDataCannotExceedWireLimit(t *testing.T) {
+ t.Parallel()
+
+ var out bytes.Buffer
+
+ bw := bufio.NewWriter(&out)
+
+ enc := pktline.NewEncoder(bw)
+ enc.SetMaxData(pktline.LargePacketDataMax + 100)
+
+ err := enc.WriteData(bytes.Repeat([]byte{'x'}, pktline.LargePacketDataMax+1))
+ if !errors.Is(err, pktline.ErrTooLarge) {
+ t.Fatalf("got err %v, want ErrTooLarge", err)
+ }
+}
diff --git a/network/protocol/pktline/encoder_writes_frames_test.go b/network/protocol/pktline/encoder_writes_frames_test.go
new file mode 100644
index 00000000..9e4275a1
--- /dev/null
+++ b/network/protocol/pktline/encoder_writes_frames_test.go
@@ -0,0 +1,51 @@
+package pktline_test
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestEncoderWritesFrames(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+
+ bw := bufio.NewWriter(&b)
+
+ enc := pktline.NewEncoder(bw)
+
+ err := enc.WriteData([]byte("hi"))
+ if err != nil {
+ t.Fatalf("WriteData: %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)
+ }
+
+ got := b.String()
+
+ want := "0006hi000000010002"
+ if got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/network/protocol/pktline/errors.go b/network/protocol/pktline/errors.go
new file mode 100644
index 00000000..866ff467
--- /dev/null
+++ b/network/protocol/pktline/errors.go
@@ -0,0 +1,31 @@
+package pktline
+
+import "errors"
+
+var (
+ // ErrInvalidLength indicates a malformed 4-byte hexadecimal length header.
+ ErrInvalidLength = errors.New("pktline: invalid length header")
+ // ErrTooLarge indicates a payload exceeds configured packet data limits.
+ ErrTooLarge = errors.New("pktline: payload too large")
+)
+
+// ProtocolError reports invalid pkt-line framing.
+//
+// It is returned for protocol violations such as invalid control values
+// (for example 0003) or non-hex length headers.
+type ProtocolError struct {
+ Header [4]byte
+ Reason string
+}
+
+func (e *ProtocolError) Error() string {
+ if e == nil {
+ return "<nil>"
+ }
+
+ if e.Reason == "" {
+ return "pktline: protocol error"
+ }
+
+ return "pktline: protocol error: " + e.Reason
+}
diff --git a/network/protocol/pktline/frame.go b/network/protocol/pktline/frame.go
new file mode 100644
index 00000000..a1cf708c
--- /dev/null
+++ b/network/protocol/pktline/frame.go
@@ -0,0 +1,10 @@
+package pktline
+
+// Frame is one decoded pkt-line frame.
+//
+// For PacketData, Payload holds frame bytes (possibly empty for 0004).
+// For control frames, Payload is nil.
+type Frame struct {
+ Type PacketType
+ Payload []byte
+}
diff --git a/network/protocol/pktline/header.go b/network/protocol/pktline/header.go
new file mode 100644
index 00000000..41e50e04
--- /dev/null
+++ b/network/protocol/pktline/header.go
@@ -0,0 +1,57 @@
+package pktline
+
+import "fmt"
+
+func hexval(b byte) int {
+ switch {
+ case b >= '0' && b <= '9':
+ return int(b - '0')
+ case b >= 'a' && b <= 'f':
+ return int(b-'a') + 10
+ case b >= 'A' && b <= 'F':
+ return int(b-'A') + 10
+ default:
+ return -1
+ }
+}
+
+// ParseLengthHeader parses a 4-byte hexadecimal pkt-line length header.
+//
+// The returned value is the full on-wire packet size, including the 4-byte
+// header. Semantic interpretation (data/control/error) is done by Decoder.
+//
+// The 4-byte header is only an actual length when above or equal to 4.
+// Otherwise, it indicates some control packet.
+func ParseLengthHeader(h [4]byte) (int, error) {
+ a := hexval(h[0])
+ b := hexval(h[1])
+ c := hexval(h[2])
+ d := hexval(h[3])
+
+ if a < 0 || b < 0 || c < 0 || d < 0 {
+ return 0, fmt.Errorf("%w: %q", ErrInvalidLength, string(h[:]))
+ }
+
+ return (a << 12) | (b << 8) | (c << 4) | d, nil
+}
+
+// EncodeLengthHeader encodes n as a 4-byte hexadecimal pkt-line header.
+//
+// n is the full on-wire packet size including the 4-byte header.
+//
+// The 4-byte header is only an actual length when above or equal to 4.
+// Otherwise, it indicates some control packet.
+func EncodeLengthHeader(dst *[4]byte, n int) error {
+ if n < 0 || n > LargePacketMax {
+ return fmt.Errorf("%w: %d", ErrInvalidLength, n)
+ }
+
+ const hex = "0123456789abcdef"
+
+ dst[0] = hex[(n>>12)&0xf]
+ dst[1] = hex[(n>>8)&0xf]
+ dst[2] = hex[(n>>4)&0xf]
+ dst[3] = hex[n&0xf]
+
+ return nil
+}
diff --git a/network/protocol/pktline/parse_length_header_test.go b/network/protocol/pktline/parse_length_header_test.go
new file mode 100644
index 00000000..b1a4c1e5
--- /dev/null
+++ b/network/protocol/pktline/parse_length_header_test.go
@@ -0,0 +1,26 @@
+package pktline_test
+
+import (
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+func TestParseLengthHeader(t *testing.T) {
+ t.Parallel()
+
+ n, err := pktline.ParseLengthHeader([4]byte{'0', '0', '0', '4'})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if n != 4 {
+ t.Fatalf("got %d, want 4", n)
+ }
+
+ _, err = pktline.ParseLengthHeader([4]byte{'0', '0', '0', 'x'})
+ if !errors.Is(err, pktline.ErrInvalidLength) {
+ t.Fatalf("got err %v, want ErrInvalidLength", err)
+ }
+}
diff --git a/network/protocol/pktline/type.go b/network/protocol/pktline/type.go
new file mode 100644
index 00000000..641d1c6c
--- /dev/null
+++ b/network/protocol/pktline/type.go
@@ -0,0 +1,15 @@
+package pktline
+
+// PacketType identifies the kind of pkt-line frame.
+type PacketType uint8
+
+const (
+ // PacketData is a regular data frame whose payload is application-defined.
+ PacketData PacketType = iota
+ // PacketFlush is control frame 0000 and marks end of a message.
+ PacketFlush
+ // PacketDelim is control frame 0001 and separates sections in protocol v2.
+ PacketDelim
+ // PacketResponseEnd is control frame 0002 and marks response end on stateless v2 transports.
+ PacketResponseEnd
+)
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
+}
diff --git a/network/protocol/v0v1/doc.go b/network/protocol/v0v1/doc.go
new file mode 100644
index 00000000..2c96ea23
--- /dev/null
+++ b/network/protocol/v0v1/doc.go
@@ -0,0 +1,2 @@
+// Package v0v1 provides common constants and routines for the V0 and V1 protocols.
+package v0v1
diff --git a/network/protocol/v0v1/server/advertise.go b/network/protocol/v0v1/server/advertise.go
new file mode 100644
index 00000000..be1b1f02
--- /dev/null
+++ b/network/protocol/v0v1/server/advertise.go
@@ -0,0 +1,55 @@
+package server
+
+import (
+ "fmt"
+ "strings"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// AdvertiseRefs writes one server ref advertisement.
+func (session *Session) AdvertiseRefs(ad Advertisement, capabilityTokens []string) error {
+ if session.opts.Version == Version1 {
+ err := session.enc.WriteData([]byte("version 1\n"))
+ if err != nil {
+ return err
+ }
+ }
+
+ capList := strings.Join(capabilityTokens, " ")
+
+ refs := sortAdvertisedRefs(ad.Refs)
+ if len(refs) == 0 {
+ line := fmt.Sprintf("%s capabilities^{}\x00%s\n", objectid.Zero(session.opts.Algorithm), capList)
+
+ err := session.enc.WriteData([]byte(line))
+ if err != nil {
+ return err
+ }
+
+ return session.WriteFlush()
+ }
+
+ for i, entry := range refs {
+ line := fmt.Sprintf("%s %s", entry.ID, entry.Name)
+ if i == 0 {
+ line += "\x00" + capList
+ }
+
+ err := session.enc.WriteData([]byte(line + "\n"))
+ if err != nil {
+ return err
+ }
+
+ if entry.Peeled != nil {
+ peeled := fmt.Sprintf("%s %s^{}\n", *entry.Peeled, entry.Name)
+
+ err = session.enc.WriteData([]byte(peeled))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return session.WriteFlush()
+}
diff --git a/network/protocol/v0v1/server/advertise_test.go b/network/protocol/v0v1/server/advertise_test.go
new file mode 100644
index 00000000..3aac7056
--- /dev/null
+++ b/network/protocol/v0v1/server/advertise_test.go
@@ -0,0 +1,101 @@
+package server_test
+
+import (
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ server "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestAdvertiseRefsWritesVersionOneHeadCapsAndPeeledTag(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ headID := mustHexID(t, algo, "1")
+ tagID := mustHexID(t, algo, "2")
+ peeledID := mustHexID(t, algo, "3")
+ mainID := mustHexID(t, algo, "4")
+
+ var out bufferWriteFlusher
+
+ session := server.NewSession(
+ strings.NewReader(""),
+ &out,
+ server.Options{
+ Version: server.Version1,
+ Algorithm: algo,
+ },
+ )
+
+ err := session.AdvertiseRefs(server.Advertisement{
+ Refs: []server.AdvertisedRef{
+ {Name: "refs/tags/v1", ID: tagID, Peeled: &peeledID},
+ {Name: "HEAD", ID: headID},
+ {Name: "refs/heads/main", ID: mainID},
+ },
+ }, []string{
+ "report-status",
+ "delete-refs",
+ "object-format=" + algo.String(),
+ "agent=furgit-test/1",
+ })
+ if err != nil {
+ t.Fatalf("AdvertiseRefs: %v", err)
+ }
+
+ got := out.String()
+ wantParts := []string{
+ "000eversion 1\n",
+ headID.String() + " HEAD\x00report-status delete-refs object-format=" + algo.String() + " agent=furgit-test/1\n",
+ mainID.String() + " refs/heads/main\n",
+ tagID.String() + " refs/tags/v1\n",
+ peeledID.String() + " refs/tags/v1^{}\n",
+ "0000",
+ }
+
+ for _, part := range wantParts {
+ if !strings.Contains(got, part) {
+ t.Fatalf("advertisement missing %q in %q", part, got)
+ }
+ }
+ })
+}
+
+func TestAdvertiseRefsWritesNoRefsCapabilitiesLine(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ var out bufferWriteFlusher
+
+ session := server.NewSession(
+ strings.NewReader(""),
+ &out,
+ server.Options{
+ Algorithm: algo,
+ },
+ )
+
+ err := session.AdvertiseRefs(server.Advertisement{}, []string{
+ "report-status",
+ "object-format=" + algo.String(),
+ })
+ if err != nil {
+ t.Fatalf("AdvertiseRefs: %v", err)
+ }
+
+ got := out.String()
+
+ want := objectid.Zero(algo).String() + " capabilities^{}\x00report-status object-format=" + algo.String() + "\n"
+ if !strings.Contains(got, want) {
+ t.Fatalf("unexpected no-refs advertisement %q", got)
+ }
+ })
+}
diff --git a/network/protocol/v0v1/server/advertised_ref.go b/network/protocol/v0v1/server/advertised_ref.go
new file mode 100644
index 00000000..cf6ddcc8
--- /dev/null
+++ b/network/protocol/v0v1/server/advertised_ref.go
@@ -0,0 +1,22 @@
+package server
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// AdvertisedRef is one ref entry in one v0/v1 server advertisement.
+type AdvertisedRef struct {
+ // Name is the advertised reference name. It may be HEAD or one full
+ // reference name.
+ Name string
+ // ID is the object ID currently advertised for Name.
+ ID objectid.ObjectID
+ // Peeled is the peeled annotated-tag target when available.
+ //
+ // If set, advertisement writes one immediate "<name>^{}" line after the
+ // main entry, matching Git's advertisement rules.
+ Peeled *objectid.ObjectID
+}
+
+// Advertisement is one server-side ref advertisement.
+type Advertisement struct {
+ Refs []AdvertisedRef
+}
diff --git a/network/protocol/v0v1/server/doc.go b/network/protocol/v0v1/server/doc.go
new file mode 100644
index 00000000..ea0b3f18
--- /dev/null
+++ b/network/protocol/v0v1/server/doc.go
@@ -0,0 +1,2 @@
+// Package server implements shared server-side Git protocol v0/v1 framing.
+package server
diff --git a/network/protocol/v0v1/server/errors.go b/network/protocol/v0v1/server/errors.go
new file mode 100644
index 00000000..6a456234
--- /dev/null
+++ b/network/protocol/v0v1/server/errors.go
@@ -0,0 +1,18 @@
+package server
+
+// ProtocolError reports one malformed or unsupported protocol input.
+type ProtocolError struct {
+ Reason string
+}
+
+// Error returns the formatted error string.
+func (err *ProtocolError) Error() string {
+ return "protocol/v0v1/server: protocol error: " + err.Reason
+}
+
+// ErrUnexpectedPacket reports one unexpected pkt-line control packet.
+var ErrUnexpectedPacket = &ProtocolError{Reason: "unexpected control packet"}
+
+// ErrSideBandNotEnabled reports one attempt to write sideband frames without a
+// negotiated side-band-64k session.
+var ErrSideBandNotEnabled = &ProtocolError{Reason: "side-band-64k not enabled"}
diff --git a/network/protocol/v0v1/server/frame.go b/network/protocol/v0v1/server/frame.go
new file mode 100644
index 00000000..ad2a0801
--- /dev/null
+++ b/network/protocol/v0v1/server/frame.go
@@ -0,0 +1,20 @@
+package server
+
+import "codeberg.org/lindenii/furgit/network/protocol/pktline"
+
+// FrameType identifies one low-level v0/v1 server pkt-line frame type.
+type FrameType = pktline.PacketType
+
+const (
+ // FrameData is one data pkt-line.
+ FrameData = pktline.PacketData
+ // FrameFlush is one flush-pkt.
+ FrameFlush = pktline.PacketFlush
+ // FrameDelim is one delim-pkt.
+ FrameDelim = pktline.PacketDelim
+ // FrameResponseEnd is one response-end-pkt.
+ FrameResponseEnd = pktline.PacketResponseEnd
+)
+
+// Frame is one decoded low-level pkt-line frame.
+type Frame = pktline.Frame
diff --git a/network/protocol/v0v1/server/helpers.go b/network/protocol/v0v1/server/helpers.go
new file mode 100644
index 00000000..9a62f714
--- /dev/null
+++ b/network/protocol/v0v1/server/helpers.go
@@ -0,0 +1,29 @@
+package server
+
+import (
+ "slices"
+)
+
+func sortAdvertisedRefs(refs []AdvertisedRef) []AdvertisedRef {
+ out := append([]AdvertisedRef(nil), refs...)
+ slices.SortFunc(out, func(left, right AdvertisedRef) int {
+ if left.Name == "HEAD" && right.Name != "HEAD" {
+ return -1
+ }
+
+ if left.Name != "HEAD" && right.Name == "HEAD" {
+ return 1
+ }
+
+ switch {
+ case left.Name < right.Name:
+ return -1
+ case left.Name > right.Name:
+ return 1
+ default:
+ return 0
+ }
+ })
+
+ return out
+}
diff --git a/network/protocol/v0v1/server/helpers_test.go b/network/protocol/v0v1/server/helpers_test.go
new file mode 100644
index 00000000..261bbdc5
--- /dev/null
+++ b/network/protocol/v0v1/server/helpers_test.go
@@ -0,0 +1,28 @@
+package server_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+type bufferWriteFlusher struct {
+ bytes.Buffer
+}
+
+func (bufferWriteFlusher) Flush() error {
+ return nil
+}
+
+func mustHexID(tb testing.TB, algo objectid.Algorithm, digit string) objectid.ObjectID {
+ tb.Helper()
+
+ id, err := objectid.ParseHex(algo, strings.Repeat(digit, algo.HexLen()))
+ if err != nil {
+ tb.Fatalf("objectid.ParseHex(%q): %v", strings.Repeat(digit, algo.HexLen()), err)
+ }
+
+ return id
+}
diff --git a/network/protocol/v0v1/server/receivepack/capabilities.go b/network/protocol/v0v1/server/receivepack/capabilities.go
new file mode 100644
index 00000000..e0ff51a3
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/capabilities.go
@@ -0,0 +1,192 @@
+package receivepack
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// Capabilities describes one receive-pack capability set.
+type Capabilities struct {
+ ReportStatus bool
+ ReportStatusV2 bool
+ DeleteRefs bool
+ SideBand64K bool
+ Quiet bool
+ Atomic bool
+ OfsDelta bool
+ PushOptions bool
+ PushCertNonce string
+ ObjectFormat objectid.Algorithm
+ SessionID string
+ Agent string
+}
+
+// Normalize returns one normalized copy of caps.
+func (caps Capabilities) Normalize(defaultAlgorithm objectid.Algorithm) Capabilities {
+ if caps.ObjectFormat == objectid.AlgorithmUnknown {
+ caps.ObjectFormat = defaultAlgorithm
+ }
+
+ return caps
+}
+
+// Tokens returns capabilities in Git advertisement order.
+func (caps Capabilities) Tokens(defaultAlgorithm objectid.Algorithm) []string {
+ caps = caps.Normalize(defaultAlgorithm)
+
+ tokens := make([]string, 0, 11)
+ if caps.ReportStatus {
+ tokens = append(tokens, "report-status")
+ }
+
+ if caps.ReportStatusV2 {
+ tokens = append(tokens, "report-status-v2")
+ }
+
+ if caps.DeleteRefs {
+ tokens = append(tokens, "delete-refs")
+ }
+
+ if caps.SideBand64K {
+ tokens = append(tokens, "side-band-64k")
+ }
+
+ if caps.Quiet {
+ tokens = append(tokens, "quiet")
+ }
+
+ if caps.Atomic {
+ tokens = append(tokens, "atomic")
+ }
+
+ if caps.OfsDelta {
+ tokens = append(tokens, "ofs-delta")
+ }
+
+ if caps.PushCertNonce != "" {
+ tokens = append(tokens, "push-cert="+caps.PushCertNonce)
+ }
+
+ if caps.PushOptions {
+ tokens = append(tokens, "push-options")
+ }
+
+ if caps.SessionID != "" {
+ tokens = append(tokens, "session-id="+caps.SessionID)
+ }
+
+ if caps.ObjectFormat != objectid.AlgorithmUnknown {
+ tokens = append(tokens, "object-format="+caps.ObjectFormat.String())
+ }
+
+ if caps.Agent != "" {
+ tokens = append(tokens, "agent="+caps.Agent)
+ }
+
+ return tokens
+}
+
+func (caps Capabilities) supportsToken(token string, defaultAlgorithm objectid.Algorithm) bool {
+ name, value, _ := strings.Cut(token, "=")
+
+ switch name {
+ case "report-status":
+ return caps.ReportStatus && value == ""
+ case "report-status-v2":
+ return caps.ReportStatusV2 && value == ""
+ case "delete-refs":
+ return caps.DeleteRefs && value == ""
+ case "side-band-64k":
+ return caps.SideBand64K && value == ""
+ case "quiet":
+ return caps.Quiet && value == ""
+ case "atomic":
+ return caps.Atomic && value == ""
+ case "ofs-delta":
+ return caps.OfsDelta && value == ""
+ case "push-options":
+ return caps.PushOptions && value == ""
+ case "push-cert":
+ return caps.PushCertNonce != "" && value != ""
+ case "object-format":
+ if value == "" {
+ return false
+ }
+
+ algo, ok := objectid.ParseAlgorithm(value)
+
+ return ok && algo == caps.Normalize(defaultAlgorithm).ObjectFormat
+ case "session-id":
+ return caps.SessionID != "" && value != ""
+ case "agent":
+ return caps.Agent != "" && value != ""
+ default:
+ return false
+ }
+}
+
+func parseCapabilityList(s string) ([]string, error) {
+ s = strings.TrimSuffix(s, "\n")
+ if s == "" {
+ return nil, nil
+ }
+
+ tokens := strings.Fields(s)
+ if slices.Contains(tokens, "") {
+ return nil, &ProtocolError{Reason: "empty capability token"}
+ }
+
+ return tokens, nil
+}
+
+func parseRequestedCapabilities(
+ tokens []string,
+ supported Capabilities,
+ defaultAlgorithm objectid.Algorithm,
+) (Capabilities, error) {
+ var requested Capabilities
+
+ requested.ObjectFormat = defaultAlgorithm
+
+ for _, token := range tokens {
+ if !supported.supportsToken(token, defaultAlgorithm) {
+ return Capabilities{}, &ProtocolError{
+ Reason: fmt.Sprintf("unsupported capability %q", token),
+ }
+ }
+
+ name, value, _ := strings.Cut(token, "=")
+ switch name {
+ case "report-status":
+ requested.ReportStatus = true
+ case "report-status-v2":
+ requested.ReportStatusV2 = true
+ case "delete-refs":
+ requested.DeleteRefs = true
+ case "side-band-64k":
+ requested.SideBand64K = true
+ case "quiet":
+ requested.Quiet = true
+ case "atomic":
+ requested.Atomic = true
+ case "ofs-delta":
+ requested.OfsDelta = true
+ case "push-options":
+ requested.PushOptions = true
+ case "push-cert":
+ requested.PushCertNonce = value
+ case "object-format":
+ algo, _ := objectid.ParseAlgorithm(value)
+ requested.ObjectFormat = algo
+ case "session-id":
+ requested.SessionID = value
+ case "agent":
+ requested.Agent = value
+ }
+ }
+
+ return requested, nil
+}
diff --git a/network/protocol/v0v1/server/receivepack/doc.go b/network/protocol/v0v1/server/receivepack/doc.go
new file mode 100644
index 00000000..65793831
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/doc.go
@@ -0,0 +1,2 @@
+// Package receivepack implements the receive-pack-specific server side of Git protocol v0/v1.
+package receivepack
diff --git a/network/protocol/v0v1/server/receivepack/errors.go b/network/protocol/v0v1/server/receivepack/errors.go
new file mode 100644
index 00000000..d89f8959
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/errors.go
@@ -0,0 +1,11 @@
+package receivepack
+
+// ProtocolError reports one malformed or unsupported receive-pack protocol input.
+type ProtocolError struct {
+ Reason string
+}
+
+// Error returns the formatted error string.
+func (err *ProtocolError) Error() string {
+ return "protocol/v0v1/server/receivepack: protocol error: " + err.Reason
+}
diff --git a/network/protocol/v0v1/server/receivepack/helpers_test.go b/network/protocol/v0v1/server/receivepack/helpers_test.go
new file mode 100644
index 00000000..5db8e6a6
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/helpers_test.go
@@ -0,0 +1,28 @@
+package receivepack_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+type bufferWriteFlusher struct {
+ bytes.Buffer
+}
+
+func (bufferWriteFlusher) Flush() error {
+ return nil
+}
+
+func mustHexID(tb testing.TB, algo objectid.Algorithm, digit string) objectid.ObjectID {
+ tb.Helper()
+
+ id, err := objectid.ParseHex(algo, strings.Repeat(digit, algo.HexLen()))
+ if err != nil {
+ tb.Fatalf("objectid.ParseHex(%q): %v", strings.Repeat(digit, algo.HexLen()), err)
+ }
+
+ return id
+}
diff --git a/network/protocol/v0v1/server/receivepack/parse_test.go b/network/protocol/v0v1/server/receivepack/parse_test.go
new file mode 100644
index 00000000..7c7cbe3b
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/parse_test.go
@@ -0,0 +1,255 @@
+package receivepack_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+ common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+ receivepack "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestReadRequestParsesCommandsAndPushOptions(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ oldZero := objectid.Zero(algo).String()
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte(
+ oldZero + " " + oneID.String() + " refs/heads/main\x00report-status push-options object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {
+ t.Fatalf("WriteData(first): %v", err)
+ }
+
+ err = enc.WriteData([]byte(
+ oneID.String() + " " + oldZero + " refs/heads/old\n",
+ ))
+ if err != nil {
+ t.Fatalf("WriteData(second): %v", err)
+ }
+
+ err = enc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush(commands): %v", err)
+ }
+
+ err = enc.WriteData([]byte("ci.skip\n"))
+ if err != nil {
+ t.Fatalf("WriteData(push-option): %v", err)
+ }
+
+ err = enc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush(push-options): %v", err)
+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{
+ ReportStatus: true,
+ PushOptions: true,
+ ObjectFormat: algo,
+ })
+
+ req, err := session.ReadRequest()
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+
+ if len(req.Commands) != 2 {
+ t.Fatalf("len(req.Commands) = %d, want 2", len(req.Commands))
+ }
+
+ if !req.Capabilities.ReportStatus || !req.Capabilities.PushOptions {
+ t.Fatalf("capabilities = %#v", req.Capabilities)
+ }
+
+ if len(req.PushOptions) != 1 || req.PushOptions[0] != "ci.skip" {
+ t.Fatalf("push options = %#v", req.PushOptions)
+ }
+
+ if !req.PackExpected {
+ t.Fatalf("PackExpected = false, want true")
+ }
+
+ if req.DeleteOnly {
+ t.Fatalf("DeleteOnly = true, want false")
+ }
+ })
+}
+
+func TestReadRequestDeleteOnlyDoesNotExpectPack(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte(
+ oneID.String() + " " + objectid.Zero(algo).String() + " refs/heads/old\x00delete-refs object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {
+ t.Fatalf("WriteData: %v", err)
+ }
+
+ err = enc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush: %v", err)
+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{
+ DeleteRefs: true,
+ ObjectFormat: algo,
+ })
+
+ req, err := session.ReadRequest()
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+
+ if req.PackExpected {
+ t.Fatalf("PackExpected = true, want false")
+ }
+
+ if !req.DeleteOnly {
+ t.Fatalf("DeleteOnly = false, want true")
+ }
+ })
+}
+
+func TestReadRequestRejectsUnsupportedCapability(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte(
+ objectid.Zero(algo).String() + " " + oneID.String() + " refs/heads/main\x00atomic object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {
+ t.Fatalf("WriteData: %v", err)
+ }
+
+ err = enc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush: %v", err)
+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{ObjectFormat: algo})
+
+ _, err = session.ReadRequest()
+ if err == nil {
+ t.Fatalf("ReadRequest error = nil, want error")
+ }
+
+ protocolErr, ok := errors.AsType[*receivepack.ProtocolError](err)
+ if !ok {
+ t.Fatalf("errors.AsType[*receivepack.ProtocolError](%T) = false", err)
+ }
+
+ if !strings.Contains(protocolErr.Reason, "unsupported capability") {
+ t.Fatalf("ProtocolError.Reason = %q", protocolErr.Reason)
+ }
+ })
+}
+
+func TestReadRequestParsesPushCertificate(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte("push-cert\x00push-cert=nonce object-format=" + algo.String() + "\n"))
+ if err != nil {
+ t.Fatalf("WriteData(push-cert): %v", err)
+ }
+
+ lines := []string{
+ "certificate version 0.1\n",
+ "pusher Example <example@example.com>\n",
+ "nonce nonce\n",
+ "push-option ci.skip\n",
+ "\n",
+ objectid.Zero(algo).String() + " " + oneID.String() + " refs/heads/main\n",
+ "-----BEGIN PGP SIGNATURE-----\n",
+ "abcdef\n",
+ "push-cert-end\n",
+ }
+
+ for _, line := range lines {
+ err = enc.WriteData([]byte(line))
+ if err != nil {
+ t.Fatalf("WriteData(%q): %v", line, err)
+ }
+ }
+
+ err = enc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush: %v", err)
+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{
+ PushCertNonce: "server-nonce",
+ ObjectFormat: algo,
+ })
+
+ req, err := session.ReadRequest()
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+
+ if req.PushCert == nil {
+ t.Fatalf("PushCert = nil, want parsed certificate")
+ }
+
+ if len(req.Commands) != 1 {
+ t.Fatalf("len(req.Commands) = %d, want 1", len(req.Commands))
+ }
+
+ if len(req.PushCert.EmbeddedOption) != 1 || req.PushCert.EmbeddedOption[0] != "ci.skip" {
+ t.Fatalf("embedded options = %#v", req.PushCert.EmbeddedOption)
+ }
+ })
+}
diff --git a/network/protocol/v0v1/server/receivepack/report_status.go b/network/protocol/v0v1/server/receivepack/report_status.go
new file mode 100644
index 00000000..fbe4fb4f
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/report_status.go
@@ -0,0 +1,185 @@
+package receivepack
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+)
+
+// WriteReportStatus writes one classic report-status response.
+func (session *Session) WriteReportStatus(result ReportStatusResult) error {
+ unpackResult := "ok"
+ if result.UnpackError != "" {
+ unpackResult = result.UnpackError
+ }
+
+ if !session.negotiated.SideBand64K {
+ err := session.base.WriteData(fmt.Appendf(nil, "unpack %s\n", unpackResult))
+ if err != nil {
+ return err
+ }
+
+ for _, command := range result.Commands {
+ line := fmt.Sprintf("ok %s\n", command.Name)
+ if command.Error != "" {
+ line = fmt.Sprintf("ng %s %s\n", command.Name, command.Error)
+ }
+
+ err = session.base.WriteData([]byte(line))
+ if err != nil {
+ return err
+ }
+ }
+
+ return session.base.WriteFlush()
+ }
+
+ buf, err := pktline.AppendData(nil, fmt.Appendf(nil, "unpack %s\n", unpackResult))
+ if err != nil {
+ return err
+ }
+
+ for _, command := range result.Commands {
+ line := fmt.Sprintf("ok %s\n", command.Name)
+ if command.Error != "" {
+ line = fmt.Sprintf("ng %s %s\n", command.Name, command.Error)
+ }
+
+ buf, err = pktline.AppendData(buf, []byte(line))
+ if err != nil {
+ return err
+ }
+ }
+
+ buf = pktline.AppendFlushPkt(buf)
+
+ w := session.base.PrimaryDataWriter()
+
+ _, err = w.Write(buf)
+ if err != nil {
+ return err
+ }
+
+ return session.base.WriteFlush()
+}
+
+// WriteReportStatusV2 writes one report-status-v2 response.
+func (session *Session) WriteReportStatusV2(result ReportStatusResult) error {
+ unpackResult := "ok"
+ if result.UnpackError != "" {
+ unpackResult = result.UnpackError
+ }
+
+ if !session.negotiated.SideBand64K { //nolint:nestif
+ err := session.base.WriteData(fmt.Appendf(nil, "unpack %s\n", unpackResult))
+ if err != nil {
+ return err
+ }
+
+ for _, command := range result.Commands {
+ if command.Error != "" {
+ err = session.base.WriteData(fmt.Appendf(nil, "ng %s %s\n", command.Name, command.Error))
+ if err != nil {
+ return err
+ }
+
+ continue
+ }
+
+ err = session.base.WriteData(fmt.Appendf(nil, "ok %s\n", command.Name))
+ if err != nil {
+ return err
+ }
+
+ if command.RefName != "" {
+ err = session.base.WriteData(fmt.Appendf(nil, "option refname %s\n", command.RefName))
+ if err != nil {
+ return err
+ }
+ }
+
+ if command.OldID != nil {
+ err = session.base.WriteData(fmt.Appendf(nil, "option old-oid %s\n", *command.OldID))
+ if err != nil {
+ return err
+ }
+ }
+
+ if command.NewID != nil {
+ err = session.base.WriteData(fmt.Appendf(nil, "option new-oid %s\n", *command.NewID))
+ if err != nil {
+ return err
+ }
+ }
+
+ if command.ForcedUpdate {
+ err = session.base.WriteData([]byte("option forced-update\n"))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return session.base.WriteFlush()
+ }
+
+ buf, err := pktline.AppendData(nil, fmt.Appendf(nil, "unpack %s\n", unpackResult))
+ if err != nil {
+ return err
+ }
+
+ for _, command := range result.Commands {
+ if command.Error != "" {
+ buf, err = pktline.AppendData(buf, fmt.Appendf(nil, "ng %s %s\n", command.Name, command.Error))
+ if err != nil {
+ return err
+ }
+
+ continue
+ }
+
+ buf, err = pktline.AppendData(buf, fmt.Appendf(nil, "ok %s\n", command.Name))
+ if err != nil {
+ return err
+ }
+
+ if command.RefName != "" {
+ buf, err = pktline.AppendData(buf, fmt.Appendf(nil, "option refname %s\n", command.RefName))
+ if err != nil {
+ return err
+ }
+ }
+
+ if command.OldID != nil {
+ buf, err = pktline.AppendData(buf, fmt.Appendf(nil, "option old-oid %s\n", *command.OldID))
+ if err != nil {
+ return err
+ }
+ }
+
+ if command.NewID != nil {
+ buf, err = pktline.AppendData(buf, fmt.Appendf(nil, "option new-oid %s\n", *command.NewID))
+ if err != nil {
+ return err
+ }
+ }
+
+ if command.ForcedUpdate {
+ buf, err = pktline.AppendData(buf, []byte("option forced-update\n"))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ buf = pktline.AppendFlushPkt(buf)
+
+ w := session.base.PrimaryDataWriter()
+
+ _, err = w.Write(buf)
+ if err != nil {
+ return err
+ }
+
+ return session.base.WriteFlush()
+}
diff --git a/network/protocol/v0v1/server/receivepack/report_status_test.go b/network/protocol/v0v1/server/receivepack/report_status_test.go
new file mode 100644
index 00000000..b9b116f6
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/report_status_test.go
@@ -0,0 +1,293 @@
+package receivepack_test
+
+import (
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+ "codeberg.org/lindenii/furgit/network/protocol/sideband64k"
+ common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+ receivepack "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestWriteReportStatusWritesClassicStatus(t *testing.T) {
+ t.Parallel()
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(""), &out, common.Options{})
+ session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+ err := session.WriteReportStatus(receivepack.ReportStatusResult{
+ Commands: []receivepack.CommandResult{
+ {Name: "refs/heads/main"},
+ {Name: "refs/heads/dev", Error: "non-fast-forward"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("WriteReportStatus: %v", err)
+ }
+
+ got := out.String()
+ wantParts := []string{
+ "unpack ok\n",
+ "ok refs/heads/main\n",
+ "ng refs/heads/dev non-fast-forward\n",
+ "0000",
+ }
+
+ for _, part := range wantParts {
+ if !strings.Contains(got, part) {
+ t.Fatalf("report-status missing %q in %q", part, got)
+ }
+ }
+}
+
+func TestWriteReportStatusUsesSideBand64KWhenNegotiated(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ var requestWire bufferWriteFlusher
+
+ requestEnc := pktline.NewEncoder(&requestWire)
+
+ err := requestEnc.WriteData([]byte(
+ objectid.Zero(algo).String() + " " + mustHexID(t, algo, "1").String() + " refs/heads/main\x00report-status side-band-64k object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {
+ t.Fatalf("WriteData(request): %v", err)
+ }
+
+ err = requestEnc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush(request): %v", err)
+ }
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(requestWire.String()), &out, common.Options{
+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{
+ ReportStatus: true,
+ SideBand64K: true,
+ ObjectFormat: algo,
+ })
+
+ _, err = session.ReadRequest()
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+
+ err = session.WriteReportStatus(receivepack.ReportStatusResult{
+ Commands: []receivepack.CommandResult{
+ {Name: "refs/heads/main"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("WriteReportStatus: %v", err)
+ }
+
+ dec := sideband64k.NewDecoder(strings.NewReader(out.String()), sideband64k.ReadOptions{})
+
+ frame, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(unpack): %v", err)
+ }
+
+ if frame.Type != sideband64k.FrameData {
+ t.Fatalf("first frame = %#v", frame)
+ }
+
+ statusDec := pktline.NewDecoder(strings.NewReader(string(frame.Payload)), pktline.ReadOptions{})
+
+ statusFrame, err := statusDec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(unpack status): %v", err)
+ }
+
+ if statusFrame.Type != pktline.PacketData || string(statusFrame.Payload) != "unpack ok\n" {
+ t.Fatalf("first status frame = %#v", statusFrame)
+ }
+
+ statusFrame, err = statusDec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(ok status): %v", err)
+ }
+
+ if statusFrame.Type != pktline.PacketData || string(statusFrame.Payload) != "ok refs/heads/main\n" {
+ t.Fatalf("second status frame = %#v", statusFrame)
+ }
+
+ statusFrame, err = statusDec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(status flush): %v", err)
+ }
+
+ if statusFrame.Type != pktline.PacketFlush {
+ t.Fatalf("status flush frame.Type = %v, want FrameFlush", statusFrame.Type)
+ }
+
+ frame, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(outer flush): %v", err)
+ }
+
+ if frame.Type != sideband64k.FrameFlush {
+ t.Fatalf("outer flush frame.Type = %v, want FrameFlush", frame.Type)
+ }
+ })
+}
+
+func TestWriteReportStatusV2WritesOptionLines(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ oldID := mustHexID(t, algo, "1")
+ newID := mustHexID(t, algo, "2")
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(""), &out, common.Options{})
+ session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+ err := session.WriteReportStatusV2(receivepack.ReportStatusResult{
+ Commands: []receivepack.CommandResult{
+ {
+ Name: "refs/pseudo/proc",
+ RefName: "refs/heads/main",
+ OldID: &oldID,
+ NewID: &newID,
+ ForcedUpdate: true,
+ },
+ {Name: "refs/heads/dev", Error: "rejected"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("WriteReportStatusV2: %v", err)
+ }
+
+ got := out.String()
+ wantParts := []string{
+ "unpack ok\n",
+ "ok refs/pseudo/proc\n",
+ "option refname refs/heads/main\n",
+ "option old-oid " + oldID.String() + "\n",
+ "option new-oid " + newID.String() + "\n",
+ "option forced-update\n",
+ "ng refs/heads/dev rejected\n",
+ "0000",
+ }
+
+ for _, part := range wantParts {
+ if !strings.Contains(got, part) {
+ t.Fatalf("report-status-v2 missing %q in %q", part, got)
+ }
+ }
+ })
+}
+
+func TestWriteProgressRequiresSideBand64K(t *testing.T) {
+ t.Parallel()
+
+ base := common.NewSession(strings.NewReader(""), &bufferWriteFlusher{}, common.Options{})
+ session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+ err := session.WriteProgress([]byte("progress\n"))
+ if !errors.Is(err, common.ErrSideBandNotEnabled) {
+ t.Fatalf("WriteProgress error = %v, want %v", err, common.ErrSideBandNotEnabled)
+ }
+}
+
+func TestProgressWriterDiscardsWithoutSideBand64K(t *testing.T) {
+ t.Parallel()
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(""), &out, common.Options{})
+ session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+ n, err := io.WriteString(session.ProgressWriter(), "progress line\n")
+ if err != nil {
+ t.Fatalf("ProgressWriter.Write: %v", err)
+ }
+
+ if n != len("progress line\n") {
+ t.Fatalf("ProgressWriter.Write n = %d, want %d", n, len("progress line\n"))
+ }
+
+ if out.String() != "" {
+ t.Fatalf("unexpected wire output without side-band-64k: %q", out.String())
+ }
+}
+
+func TestProgressWriterUsesSideBand64KWhenNegotiated(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ var requestWire bufferWriteFlusher
+
+ requestEnc := pktline.NewEncoder(&requestWire)
+
+ err := requestEnc.WriteData([]byte(
+ objectid.Zero(algo).String() + " " + mustHexID(t, algo, "1").String() + " refs/heads/main\x00report-status side-band-64k object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {
+ t.Fatalf("WriteData(request): %v", err)
+ }
+
+ err = requestEnc.WriteFlush()
+ if err != nil {
+ t.Fatalf("WriteFlush(request): %v", err)
+ }
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(requestWire.String()), &out, common.Options{
+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{
+ ReportStatus: true,
+ SideBand64K: true,
+ ObjectFormat: algo,
+ })
+
+ _, err = session.ReadRequest()
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+
+ _, err = io.WriteString(session.ProgressWriter(), "remote: stage 1\r")
+ if err != nil {
+ t.Fatalf("ProgressWriter.Write: %v", err)
+ }
+
+ dec := sideband64k.NewDecoder(strings.NewReader(out.String()), sideband64k.ReadOptions{})
+
+ frame, err := dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(progress): %v", err)
+ }
+
+ if frame.Type != sideband64k.FrameProgress {
+ t.Fatalf("frame.Type = %v, want FrameProgress", frame.Type)
+ }
+
+ if string(frame.Payload) != "remote: stage 1\r" {
+ t.Fatalf("frame.Payload = %q, want %q", frame.Payload, "remote: stage 1\r")
+ }
+ })
+}
diff --git a/network/protocol/v0v1/server/receivepack/session.go b/network/protocol/v0v1/server/receivepack/session.go
new file mode 100644
index 00000000..55019714
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/session.go
@@ -0,0 +1,295 @@
+package receivepack
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// Session is one stateful server-side receive-pack protocol session.
+type Session struct {
+ base *common.Session
+ supported Capabilities
+ negotiated Capabilities
+}
+
+// NewSession creates one receive-pack session over one common server session.
+func NewSession(base *common.Session, supported Capabilities) *Session {
+ return &Session{
+ base: base,
+ supported: supported,
+ }
+}
+
+// AdvertiseRefs writes one receive-pack ref advertisement.
+func (session *Session) AdvertiseRefs(ad common.Advertisement) error {
+ return session.base.AdvertiseRefs(ad, session.supported.Tokens(session.base.Algorithm()))
+}
+
+// ReadRequest reads one receive-pack request through optional push-options.
+func (session *Session) ReadRequest() (*Request, error) {
+ req := &Request{}
+
+ var sawCommands bool
+
+ for {
+ frame, err := session.base.ReadFrame()
+ if err != nil {
+ return nil, err
+ }
+
+ switch frame.Type {
+ case common.FrameFlush:
+ goto afterCommands
+ case common.FrameData:
+ case common.FrameDelim, common.FrameResponseEnd:
+ return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}
+ }
+
+ payload := string(frame.Payload)
+ if strings.HasPrefix(payload, "shallow ") {
+ line := trimOneLF(payload)
+
+ shallowID, err := parseObjectID(session.base.Algorithm(), line[len("shallow "):])
+ if err != nil {
+ return nil, err
+ }
+
+ req.Shallow = append(req.Shallow, shallowID)
+
+ continue
+ }
+
+ if strings.HasPrefix(payload, "push-cert\x00") {
+ if sawCommands {
+ return nil, &ProtocolError{Reason: "got both push certificate and unsigned commands"}
+ }
+
+ capabilityTokens, err := parseCapabilityList(payload[len("push-cert\x00"):])
+ if err != nil {
+ return nil, err
+ }
+
+ requested, err := parseRequestedCapabilities(
+ capabilityTokens,
+ session.supported,
+ session.base.Algorithm(),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ req.Capabilities = requested
+
+ cert, err := session.readPushCertificate()
+ if err != nil {
+ return nil, err
+ }
+
+ req.PushCert = cert
+ req.Commands = append(req.Commands, cert.Commands...)
+ sawCommands = true
+
+ continue
+ }
+
+ line := trimOneLF(payload)
+ if !sawCommands && strings.Contains(line, "\x00") {
+ commandPart, capPart, _ := strings.Cut(line, "\x00")
+
+ capabilityTokens, err := parseCapabilityList(capPart)
+ if err != nil {
+ return nil, err
+ }
+
+ requested, err := parseRequestedCapabilities(
+ capabilityTokens,
+ session.supported,
+ session.base.Algorithm(),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ req.Capabilities = requested
+ line = commandPart
+ }
+
+ cmd, err := parseCommand(session.base.Algorithm(), line)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Commands = append(req.Commands, cmd)
+ sawCommands = true
+ }
+
+afterCommands:
+ if req.Capabilities.PushOptions {
+ for {
+ frame, err := session.base.ReadFrame()
+ if err != nil {
+ return nil, err
+ }
+
+ switch frame.Type {
+ case common.FrameFlush:
+ goto afterPushOptions
+ case common.FrameData:
+ req.PushOptions = append(req.PushOptions, trimOneLF(string(frame.Payload)))
+ case common.FrameDelim, common.FrameResponseEnd:
+ return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}
+ }
+ }
+ }
+
+afterPushOptions:
+ req.DeleteOnly = deleteOnly(req.Commands)
+
+ req.PackExpected = len(req.Commands) > 0 && !req.DeleteOnly
+
+ session.negotiated = req.Capabilities
+
+ if req.Capabilities.SideBand64K {
+ session.base.EnableSideBand64K()
+ }
+
+ return req, nil
+}
+
+// WriteProgress writes one progress packet.
+func (session *Session) WriteProgress(p []byte) error {
+ return session.base.WriteProgress(p)
+}
+
+// ProgressWriter returns one chunking writer for sideband progress output.
+//
+// When side-band-64k was not negotiated, writes are discarded.
+func (session *Session) ProgressWriter() io.Writer {
+ return session.base.ProgressWriter()
+}
+
+// WriteError writes one fatal error packet.
+func (session *Session) WriteError(p []byte) error {
+ return session.base.WriteError(p)
+}
+
+// ErrorWriter returns one chunking writer for sideband error output.
+//
+// When side-band-64k was not negotiated, writes are discarded.
+func (session *Session) ErrorWriter() io.Writer {
+ return session.base.ErrorWriter()
+}
+
+func trimOneLF(s string) string {
+ return strings.TrimSuffix(s, "\n")
+}
+
+func parseObjectID(algo objectid.Algorithm, s string) (objectid.ObjectID, error) {
+ id, err := objectid.ParseHex(algo, s)
+ if err != nil {
+ return objectid.ObjectID{}, &ProtocolError{
+ Reason: fmt.Sprintf("invalid object id %q", s),
+ }
+ }
+
+ return id, nil
+}
+
+func commandIsDelete(cmd Command) bool {
+ return cmd.NewID == objectid.Zero(cmd.NewID.Algorithm())
+}
+
+func deleteOnly(commands []Command) bool {
+ if len(commands) == 0 {
+ return false
+ }
+
+ for _, cmd := range commands {
+ if !commandIsDelete(cmd) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func parseCommand(algo objectid.Algorithm, line string) (Command, error) {
+ fields := strings.Fields(line)
+ if len(fields) != 3 {
+ return Command{}, &ProtocolError{Reason: fmt.Sprintf("malformed command %q", line)}
+ }
+
+ oldID, err := parseObjectID(algo, fields[0])
+ if err != nil {
+ return Command{}, err
+ }
+
+ newID, err := parseObjectID(algo, fields[1])
+ if err != nil {
+ return Command{}, err
+ }
+
+ return Command{OldID: oldID, NewID: newID, Name: fields[2]}, nil
+}
+
+func (session *Session) readPushCertificate() (*PushCertificate, error) {
+ cert := &PushCertificate{}
+ inCommands := false
+ inSignature := false
+
+ for {
+ frame, err := session.base.ReadFrame()
+ if err != nil {
+ return nil, err
+ }
+
+ switch frame.Type {
+ case common.FrameFlush:
+ return nil, &ProtocolError{Reason: "unexpected flush inside push certificate"}
+ case common.FrameData:
+ case common.FrameDelim, common.FrameResponseEnd:
+ return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}
+ }
+
+ line := string(frame.Payload)
+ if line == "push-cert-end\n" {
+ return cert, nil
+ }
+
+ if !inCommands {
+ if line == "\n" {
+ inCommands = true
+
+ continue
+ }
+
+ trimmed := trimOneLF(line)
+ cert.HeaderLines = append(cert.HeaderLines, trimmed)
+
+ if strings.HasPrefix(trimmed, "push-option ") {
+ cert.EmbeddedOption = append(cert.EmbeddedOption, trimmed[len("push-option "):])
+ }
+
+ continue
+ }
+
+ if !inSignature {
+ trimmed := trimOneLF(line)
+
+ cmd, err := parseCommand(session.base.Algorithm(), trimmed)
+ if err == nil {
+ cert.Commands = append(cert.Commands, cmd)
+
+ continue
+ }
+
+ inSignature = true
+ }
+
+ cert.SignatureLines = append(cert.SignatureLines, trimOneLF(line))
+ }
+}
diff --git a/network/protocol/v0v1/server/receivepack/types.go b/network/protocol/v0v1/server/receivepack/types.go
new file mode 100644
index 00000000..b281a86b
--- /dev/null
+++ b/network/protocol/v0v1/server/receivepack/types.go
@@ -0,0 +1,45 @@
+package receivepack
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Command is one requested reference update.
+type Command struct {
+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+ Name string
+}
+
+// PushCertificate is one parsed push certificate block.
+type PushCertificate struct {
+ HeaderLines []string
+ EmbeddedOption []string
+ Commands []Command
+ SignatureLines []string
+}
+
+// Request is one parsed receive-pack request.
+type Request struct {
+ Capabilities Capabilities
+ Shallow []objectid.ObjectID
+ Commands []Command
+ PushCert *PushCertificate
+ PushOptions []string
+ PackExpected bool
+ DeleteOnly bool
+}
+
+// CommandResult is one per-command report-status result.
+type CommandResult struct {
+ Name string
+ Error string
+ RefName string
+ OldID *objectid.ObjectID
+ NewID *objectid.ObjectID
+ ForcedUpdate bool
+}
+
+// ReportStatusResult is one report-status payload.
+type ReportStatusResult struct {
+ UnpackError string
+ Commands []CommandResult
+}
diff --git a/network/protocol/v0v1/server/session.go b/network/protocol/v0v1/server/session.go
new file mode 100644
index 00000000..ab79a7d7
--- /dev/null
+++ b/network/protocol/v0v1/server/session.go
@@ -0,0 +1,131 @@
+package server
+
+import (
+ "io"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+ "codeberg.org/lindenii/furgit/network/protocol/sideband64k"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// Options configures one server-side v0/v1 session.
+type Options struct {
+ // Version selects protocol v0 or v1 framing.
+ Version Version
+ // Algorithm is the repository object ID algorithm for this session.
+ Algorithm objectid.Algorithm
+}
+
+// Session is one stateful server-side v0/v1 server protocol session.
+type Session struct {
+ dec *pktline.Decoder
+ enc *pktline.Encoder
+ sideband *sideband64k.Encoder
+ opts Options
+ useSideBand bool
+}
+
+// NewSession creates one v0/v1 server session over r and w.
+func NewSession(r io.Reader, w pktline.WriteFlusher, opts Options) *Session {
+ return &Session{
+ dec: pktline.NewDecoder(r, pktline.ReadOptions{}),
+ enc: pktline.NewEncoder(w),
+ sideband: sideband64k.NewEncoder(w),
+ opts: opts,
+ }
+}
+
+// Algorithm returns the session object ID algorithm.
+func (session *Session) Algorithm() objectid.Algorithm {
+ return session.opts.Algorithm
+}
+
+// ReadFrame reads one low-level pkt-line frame from the session input.
+func (session *Session) ReadFrame() (Frame, error) {
+ return session.dec.ReadFrame()
+}
+
+// EnableSideBand64K enables side-band-64k output framing for subsequent data,
+// progress, error, and flush writes.
+func (session *Session) EnableSideBand64K() {
+ session.useSideBand = true
+}
+
+// WriteData writes one primary output packet.
+func (session *Session) WriteData(p []byte) error {
+ if session.useSideBand {
+ return session.sideband.WriteData(p)
+ }
+
+ return session.enc.WriteData(p)
+}
+
+// WriteProgress writes one progress packet.
+func (session *Session) WriteProgress(p []byte) error {
+ if !session.useSideBand {
+ return ErrSideBandNotEnabled
+ }
+
+ return session.sideband.WriteProgress(p)
+}
+
+// WriteError writes one fatal error packet.
+func (session *Session) WriteError(p []byte) error {
+ if !session.useSideBand {
+ return ErrSideBandNotEnabled
+ }
+
+ return session.sideband.WriteError(p)
+}
+
+// WriteFlush writes one trailing flush packet.
+func (session *Session) WriteFlush() error {
+ if session.useSideBand {
+ return session.sideband.WriteFlush()
+ }
+
+ return session.enc.WriteFlush()
+}
+
+// FlushIO flushes buffered transport output without emitting pkt-line frames.
+func (session *Session) FlushIO() error {
+ if session.useSideBand {
+ return session.sideband.FlushIO()
+ }
+
+ return session.enc.FlushIO()
+}
+
+// ProgressWriter returns one chunking writer for sideband progress output.
+//
+// When side-band-64k was not negotiated, writes are discarded.
+func (session *Session) ProgressWriter() io.Writer {
+ if !session.useSideBand {
+ return io.Discard
+ }
+
+ return sideband64k.NewChunkWriter(session.sideband, sideband64k.BandProgress)
+}
+
+// ErrorWriter returns one chunking writer for sideband error output.
+//
+// When side-band-64k was not negotiated, writes are discarded.
+func (session *Session) ErrorWriter() io.Writer {
+ if !session.useSideBand {
+ return io.Discard
+ }
+
+ return sideband64k.NewChunkWriter(session.sideband, sideband64k.BandError)
+}
+
+// PrimaryDataWriter returns one chunking writer for primary output bytes.
+//
+// When side-band-64k is enabled, writes are chunked into band-1 sideband
+// frames. Otherwise writes are chunked into direct pkt-line data frames.
+func (session *Session) PrimaryDataWriter() io.Writer {
+ if session.useSideBand {
+ return sideband64k.NewChunkWriter(session.sideband, sideband64k.BandData)
+ }
+
+ return pktline.NewChunkWriter(session.enc)
+}
diff --git a/network/protocol/v0v1/server/version.go b/network/protocol/v0v1/server/version.go
new file mode 100644
index 00000000..23ae9466
--- /dev/null
+++ b/network/protocol/v0v1/server/version.go
@@ -0,0 +1,12 @@
+package server
+
+// Version identifies the protocol version used on one v0/v1 server session.
+type Version uint8
+
+const (
+ // Version0 is the original protocol framing with no leading version line.
+ Version0 Version = iota
+ // Version1 is protocol v1, which is v0 plus one leading "version 1\n"
+ // pkt-line before ref advertisement.
+ Version1
+)