diff options
Diffstat (limited to 'format')
25 files changed, 1184 insertions, 0 deletions
diff --git a/format/pktline/append.go b/format/pktline/append.go new file mode 100644 index 00000000..9425e58e --- /dev/null +++ b/format/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/format/pktline/append_data_preserves_dst_on_error_test.go b/format/pktline/append_data_preserves_dst_on_error_test.go new file mode 100644 index 00000000..35912666 --- /dev/null +++ b/format/pktline/append_data_preserves_dst_on_error_test.go @@ -0,0 +1,25 @@ +package pktline_test + +import ( + "bytes" + "errors" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/append_helpers_test.go b/format/pktline/append_helpers_test.go new file mode 100644 index 00000000..4e213dc3 --- /dev/null +++ b/format/pktline/append_helpers_test.go @@ -0,0 +1,24 @@ +package pktline_test + +import ( + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/chunk_writer.go b/format/pktline/chunk_writer.go new file mode 100644 index 00000000..b258ff20 --- /dev/null +++ b/format/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/format/pktline/chunk_writer_write_and_read_from_test.go b/format/pktline/chunk_writer_write_and_read_from_test.go new file mode 100644 index 00000000..ac6e88de --- /dev/null +++ b/format/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/format/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/format/pktline/constants.go b/format/pktline/constants.go new file mode 100644 index 00000000..811eb3c6 --- /dev/null +++ b/format/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/format/pktline/decoder.go b/format/pktline/decoder.go new file mode 100644 index 00000000..dd40de1d --- /dev/null +++ b/format/pktline/decoder.go @@ -0,0 +1,185 @@ +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 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/format/pktline/decoder_data_control_and_0004_test.go b/format/pktline/decoder_data_control_and_0004_test.go new file mode 100644 index 00000000..f04f3aa5 --- /dev/null +++ b/format/pktline/decoder_data_control_and_0004_test.go @@ -0,0 +1,60 @@ +package pktline_test + +import ( + "strings" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/decoder_invalid_0003_test.go b/format/pktline/decoder_invalid_0003_test.go new file mode 100644 index 00000000..e96beedf --- /dev/null +++ b/format/pktline/decoder_invalid_0003_test.go @@ -0,0 +1,21 @@ +package pktline_test + +import ( + "errors" + "strings" + "testing" + "codeberg.org/lindenii/furgit/format/pktline" +) + +func TestDecoderInvalid0003(t *testing.T) { + t.Parallel() + + dec := pktline.NewDecoder(strings.NewReader("0003"), pktline.ReadOptions{}) + _, err := dec.ReadFrame() + + var pe *pktline.ProtocolError + if !errors.As(err, &pe) { + t.Fatalf("got err %v, want ProtocolError", err) + } +} + diff --git a/format/pktline/decoder_peek_test.go b/format/pktline/decoder_peek_test.go new file mode 100644 index 00000000..604f6a56 --- /dev/null +++ b/format/pktline/decoder_peek_test.go @@ -0,0 +1,32 @@ +package pktline_test + +import ( + "strings" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/decoder_rejects_over_maximum_length_test.go b/format/pktline/decoder_rejects_over_maximum_length_test.go new file mode 100644 index 00000000..bd7eed66 --- /dev/null +++ b/format/pktline/decoder_rejects_over_maximum_length_test.go @@ -0,0 +1,23 @@ +package pktline_test + +import ( + "errors" + "strings" + "testing" + "codeberg.org/lindenii/furgit/format/pktline" +) + +func TestDecoderRejectsOverMaximumLength(t *testing.T) { + t.Parallel() + + dec := pktline.NewDecoder(strings.NewReader("fffe"), pktline.ReadOptions{}) + dec.SetMaxData(70000) + + _, err := dec.ReadFrame() + + var pe *pktline.ProtocolError + if !errors.As(err, &pe) { + t.Fatalf("got err %v, want ProtocolError", err) + } +} + diff --git a/format/pktline/decoder_resync_after_over_max_data_test.go b/format/pktline/decoder_resync_after_over_max_data_test.go new file mode 100644 index 00000000..c88107ae --- /dev/null +++ b/format/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/format/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/format/pktline/decoder_resync_after_over_wire_max_test.go b/format/pktline/decoder_resync_after_over_wire_max_test.go new file mode 100644 index 00000000..a355f8b7 --- /dev/null +++ b/format/pktline/decoder_resync_after_over_wire_max_test.go @@ -0,0 +1,38 @@ +package pktline_test + +import ( + "bytes" + "errors" + "testing" + "codeberg.org/lindenii/furgit/format/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() + + var pe *pktline.ProtocolError + if !errors.As(err, &pe) { + 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/format/pktline/decoder_unexpected_eof_test.go b/format/pktline/decoder_unexpected_eof_test.go new file mode 100644 index 00000000..f35fd8a7 --- /dev/null +++ b/format/pktline/decoder_unexpected_eof_test.go @@ -0,0 +1,21 @@ +package pktline_test + +import ( + "errors" + "io" + "strings" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/encode_length_header_test.go b/format/pktline/encode_length_header_test.go new file mode 100644 index 00000000..9160e22d --- /dev/null +++ b/format/pktline/encode_length_header_test.go @@ -0,0 +1,28 @@ +package pktline_test + +import ( + "errors" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/encoder.go b/format/pktline/encoder.go new file mode 100644 index 00000000..b4c6dbf0 --- /dev/null +++ b/format/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/format/pktline/encoder_buffered_flush_and_f_flush_test.go b/format/pktline/encoder_buffered_flush_and_f_flush_test.go new file mode 100644 index 00000000..d7a772b4 --- /dev/null +++ b/format/pktline/encoder_buffered_flush_and_f_flush_test.go @@ -0,0 +1,50 @@ +package pktline_test + +import ( + "bufio" + "bytes" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/encoder_buffered_flush_behavior_test.go b/format/pktline/encoder_buffered_flush_behavior_test.go new file mode 100644 index 00000000..09bac9f9 --- /dev/null +++ b/format/pktline/encoder_buffered_flush_behavior_test.go @@ -0,0 +1,86 @@ +package pktline_test + +import ( + "bufio" + "bytes" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go b/format/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go new file mode 100644 index 00000000..5b3d4855 --- /dev/null +++ b/format/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/format/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/format/pktline/encoder_writes_frames_test.go b/format/pktline/encoder_writes_frames_test.go new file mode 100644 index 00000000..c7b625fe --- /dev/null +++ b/format/pktline/encoder_writes_frames_test.go @@ -0,0 +1,51 @@ +package pktline_test + +import ( + "bufio" + "bytes" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/errors.go b/format/pktline/errors.go new file mode 100644 index 00000000..d07227e6 --- /dev/null +++ b/format/pktline/errors.go @@ -0,0 +1,34 @@ +package pktline + +import ( + "errors" + "fmt" +) + +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 fmt.Sprintf("pktline: protocol error: %s", e.Reason) +} diff --git a/format/pktline/frame.go b/format/pktline/frame.go new file mode 100644 index 00000000..a1cf708c --- /dev/null +++ b/format/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/format/pktline/header.go b/format/pktline/header.go new file mode 100644 index 00000000..41e50e04 --- /dev/null +++ b/format/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/format/pktline/parse_length_header_test.go b/format/pktline/parse_length_header_test.go new file mode 100644 index 00000000..469e4f7e --- /dev/null +++ b/format/pktline/parse_length_header_test.go @@ -0,0 +1,26 @@ +package pktline_test + +import ( + "errors" + "testing" + "codeberg.org/lindenii/furgit/format/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/format/pktline/type.go b/format/pktline/type.go new file mode 100644 index 00000000..641d1c6c --- /dev/null +++ b/format/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 +) |
