shithub: furgit

Download patch

ref: 83c03b605df05ee4c3b60f1aea0411a0636ca0b5
parent: 3ecd35180fa8cb842589e28744fed7d130120dc1
author: Runxi Yu <me@runxiyu.org>
date: Sat Feb 21 13:11:49 EST 2026

iolimit: Add ExpectLengthReader

--- /dev/null
+++ b/internal/iolimit/expect_length_reader.go
@@ -1,0 +1,76 @@
+// Package iolimit provides small internal reader wrappers for length-constrained
+// stream I/O.
+package iolimit
+
+import (
+	"errors"
+	"io"
+)
+
+// ErrExpectedLengthExceeded reports that a stream produced bytes beyond the
+// expected length.
+var ErrExpectedLengthExceeded = errors.New("iolimit: stream exceeded expected length")
+
+// ExpectLengthReader wraps src and enforces an expected byte length.
+//
+// It returns io.ErrUnexpectedEOF if src ends before expected bytes are read.
+// It returns ErrExpectedLengthExceeded if reads continue beyond the expected
+// boundary and src still produces bytes.
+//
+// This reader does not drain src on close or at the expected boundary. As a
+// result, overlength streams are detected only when a caller reads at or past
+// the boundary.
+func ExpectLengthReader(src io.Reader, expected int64) io.Reader {
+	return &expectLengthReader{
+		src:       src,
+		remaining: expected,
+	}
+}
+
+type expectLengthReader struct {
+	src       io.Reader
+	remaining int64
+}
+
+func (reader *expectLengthReader) Read(dst []byte) (int, error) {
+	if len(dst) == 0 {
+		return 0, nil
+	}
+
+	if reader.remaining == 0 {
+		var probe [1]byte
+		n, err := reader.src.Read(probe[:])
+		if n > 0 {
+			return 0, ErrExpectedLengthExceeded
+		}
+		if err == nil {
+			return 0, nil
+		}
+		return 0, err
+	}
+
+	if reader.remaining < 0 {
+		return 0, ErrExpectedLengthExceeded
+	}
+
+	if int64(len(dst)) > reader.remaining {
+		dst = dst[:reader.remaining]
+	}
+
+	n, err := reader.src.Read(dst)
+	if n > 0 {
+		reader.remaining -= int64(n)
+	}
+
+	if err == io.EOF {
+		if reader.remaining > 0 {
+			return n, io.ErrUnexpectedEOF
+		}
+		if n > 0 {
+			return n, nil
+		}
+		return 0, io.EOF
+	}
+
+	return n, err
+}
--- /dev/null
+++ b/internal/iolimit/expect_length_reader_test.go
@@ -1,0 +1,70 @@
+package iolimit_test
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/iolimit"
+)
+
+func TestExpectLengthReaderExact(t *testing.T) {
+	t.Parallel()
+
+	r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hello")), 5)
+	got, err := io.ReadAll(r)
+	if err != nil {
+		t.Fatalf("ReadAll error: %v", err)
+	}
+	if !bytes.Equal(got, []byte("hello")) {
+		t.Fatalf("ReadAll = %q, want %q", got, "hello")
+	}
+
+	buf := make([]byte, 1)
+	n, err := r.Read(buf)
+	if n != 0 || !errors.Is(err, io.EOF) {
+		t.Fatalf("post-boundary Read = (%d,%v), want (0,EOF)", n, err)
+	}
+}
+
+func TestExpectLengthReaderShort(t *testing.T) {
+	t.Parallel()
+
+	r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hey")), 5)
+	_, err := io.ReadAll(r)
+	if !errors.Is(err, io.ErrUnexpectedEOF) {
+		t.Fatalf("ReadAll error = %v, want ErrUnexpectedEOF", err)
+	}
+}
+
+func TestExpectLengthReaderLongDetectedOnNextRead(t *testing.T) {
+	t.Parallel()
+
+	r := iolimit.ExpectLengthReader(bytes.NewReader([]byte("hello!")), 5)
+	buf := make([]byte, 5)
+	n, err := io.ReadFull(r, buf)
+	if err != nil {
+		t.Fatalf("ReadFull error: %v", err)
+	}
+	if n != 5 || !bytes.Equal(buf, []byte("hello")) {
+		t.Fatalf("ReadFull = (%d,%q), want (5,hello)", n, buf)
+	}
+
+	probe := make([]byte, 1)
+	n, err = r.Read(probe)
+	if n != 0 || !errors.Is(err, iolimit.ErrExpectedLengthExceeded) {
+		t.Fatalf("overflow Read = (%d,%v), want (0,ErrExpectedLengthExceeded)", n, err)
+	}
+}
+
+func TestExpectLengthReaderEmptyExpected(t *testing.T) {
+	t.Parallel()
+
+	r := iolimit.ExpectLengthReader(bytes.NewReader(nil), 0)
+	buf := make([]byte, 1)
+	n, err := r.Read(buf)
+	if n != 0 || !errors.Is(err, io.EOF) {
+		t.Fatalf("Read = (%d,%v), want (0,EOF)", n, err)
+	}
+}
--