aboutsummaryrefslogtreecommitdiff
path: root/format/pktline
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-03-06 13:01:12 +0800
committerGravatar Runxi Yu2026-03-06 13:57:57 +0800
commit3307715a8d8bdeac1b2d7df66ec2abb6e503ba9a (patch)
tree4505d11668a6160aa0b04f38811f7a0a721b5db0 /format/pktline
parent*: go fix ./... (diff)
signatureNo signature
format/pktline: Add pktline v0.1.62
Diffstat (limited to 'format/pktline')
-rw-r--r--format/pktline/append.go39
-rw-r--r--format/pktline/append_data_preserves_dst_on_error_test.go25
-rw-r--r--format/pktline/append_helpers_test.go24
-rw-r--r--format/pktline/chunk_writer.go65
-rw-r--r--format/pktline/chunk_writer_write_and_read_from_test.go60
-rw-r--r--format/pktline/constants.go12
-rw-r--r--format/pktline/decoder.go185
-rw-r--r--format/pktline/decoder_data_control_and_0004_test.go60
-rw-r--r--format/pktline/decoder_invalid_0003_test.go21
-rw-r--r--format/pktline/decoder_peek_test.go32
-rw-r--r--format/pktline/decoder_rejects_over_maximum_length_test.go23
-rw-r--r--format/pktline/decoder_resync_after_over_max_data_test.go51
-rw-r--r--format/pktline/decoder_resync_after_over_wire_max_test.go38
-rw-r--r--format/pktline/decoder_unexpected_eof_test.go21
-rw-r--r--format/pktline/encode_length_header_test.go28
-rw-r--r--format/pktline/encoder.go145
-rw-r--r--format/pktline/encoder_buffered_flush_and_f_flush_test.go50
-rw-r--r--format/pktline/encoder_buffered_flush_behavior_test.go86
-rw-r--r--format/pktline/encoder_set_max_data_cannot_exceed_wire_limit_test.go26
-rw-r--r--format/pktline/encoder_writes_frames_test.go51
-rw-r--r--format/pktline/errors.go34
-rw-r--r--format/pktline/frame.go10
-rw-r--r--format/pktline/header.go57
-rw-r--r--format/pktline/parse_length_header_test.go26
-rw-r--r--format/pktline/type.go15
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
+)