diff options
Diffstat (limited to 'network')
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 +) |
