aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-08 09:58:26 +0000
committerGravatar Runxi Yu2026-06-08 09:58:26 +0000
commit71f44dca0b6210fa501e9d8450ee3d7bf5f73347 (patch)
treeaf6af234e9f12d8aa71e59ffb77ae06635df289b
parentREFATOR: object and object/fetch are done (diff)
signatureNo signature
internal/iolimit: Add
Might move this to lgo sometime
-rw-r--r--internal/iolimit/capped_capture_writer.go56
-rw-r--r--internal/iolimit/capped_capture_writer_test.go45
-rw-r--r--internal/iolimit/doc.go6
-rw-r--r--internal/iolimit/expect_length_reader.go78
-rw-r--r--internal/iolimit/expect_length_reader_test.go78
5 files changed, 263 insertions, 0 deletions
diff --git a/internal/iolimit/capped_capture_writer.go b/internal/iolimit/capped_capture_writer.go
new file mode 100644
index 00000000..1852836e
--- /dev/null
+++ b/internal/iolimit/capped_capture_writer.go
@@ -0,0 +1,56 @@
+package iolimit
+
+import "bytes"
+
+// CappedCaptureWriter captures written bytes up to a fixed limit.
+//
+// Once the total written bytes would exceed the limit,
+// capture is disabled and Bytes returns nil.
+// Write still reports success for the full input length.
+type CappedCaptureWriter struct {
+ limit uint64
+ buf bytes.Buffer
+ full bool
+}
+
+// NewCappedCaptureWriter constructs one capped capture writer.
+func NewCappedCaptureWriter(limit uint64) *CappedCaptureWriter {
+ return &CappedCaptureWriter{limit: limit}
+}
+
+// Write captures up to the configured limit
+// and always reports len(src) bytes written.
+func (writer *CappedCaptureWriter) Write(src []byte) (int, error) {
+ if writer.full {
+ return len(src), nil
+ }
+
+ used := uint64(writer.buf.Len())
+ if used >= writer.limit {
+ writer.full = true
+
+ return len(src), nil
+ }
+
+ room := writer.limit - used
+ if uint64(len(src)) > room {
+ _, _ = writer.buf.Write(src[:int(room)])
+ writer.full = true
+
+ return len(src), nil
+ }
+
+ _, _ = writer.buf.Write(src)
+
+ return len(src), nil
+}
+
+// Bytes returns captured bytes,
+// or nil when capture exceeded the limit.
+func (writer *CappedCaptureWriter) Bytes() []byte {
+ if writer.full {
+ return nil
+ }
+
+ return writer.buf.Bytes()
+}
diff --git a/internal/iolimit/capped_capture_writer_test.go b/internal/iolimit/capped_capture_writer_test.go
new file mode 100644
index 00000000..2793f8cb
--- /dev/null
+++ b/internal/iolimit/capped_capture_writer_test.go
@@ -0,0 +1,45 @@
+package iolimit_test
+
+import (
+ "bytes"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/iolimit"
+)
+
+func TestCappedCaptureWriterWithinLimit(t *testing.T) {
+ t.Parallel()
+
+ writer := iolimit.NewCappedCaptureWriter(8)
+
+ _, _ = writer.Write([]byte("hello"))
+ _, _ = writer.Write([]byte("!"))
+
+ if got := writer.Bytes(); !bytes.Equal(got, []byte("hello!")) {
+ t.Fatalf("Bytes() = %q, want %q", got, "hello!")
+ }
+}
+
+func TestCappedCaptureWriterExceededLimit(t *testing.T) {
+ t.Parallel()
+
+ writer := iolimit.NewCappedCaptureWriter(4)
+
+ _, _ = writer.Write([]byte("abcd"))
+ _, _ = writer.Write([]byte("x"))
+
+ if got := writer.Bytes(); got != nil {
+ t.Fatalf("Bytes() = %q, want nil after overflow", got)
+ }
+}
+
+func TestCappedCaptureWriterZeroLimit(t *testing.T) {
+ t.Parallel()
+
+ writer := iolimit.NewCappedCaptureWriter(0)
+
+ _, _ = writer.Write([]byte("x"))
+ if got := writer.Bytes(); got != nil {
+ t.Fatalf("Bytes() = %q, want nil at zero limit", got)
+ }
+}
diff --git a/internal/iolimit/doc.go b/internal/iolimit/doc.go
new file mode 100644
index 00000000..5eb72b2b
--- /dev/null
+++ b/internal/iolimit/doc.go
@@ -0,0 +1,6 @@
+// Package iolimit provides small internal I/O wrappers with bounded behavior.
+//
+// It includes helpers for both readers and writers
+// that enforce configured limits
+// (length checks, capped capture, etc.).
+package iolimit
diff --git a/internal/iolimit/expect_length_reader.go b/internal/iolimit/expect_length_reader.go
new file mode 100644
index 00000000..317f1b16
--- /dev/null
+++ b/internal/iolimit/expect_length_reader.go
@@ -0,0 +1,78 @@
+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 uint64) io.Reader {
+ return &expectLengthReader{
+ src: src,
+ remaining: expected,
+ }
+}
+
+type expectLengthReader struct {
+ src io.Reader
+ remaining uint64
+}
+
+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 //nolint:wrapcheck
+ }
+
+ if uint64(len(dst)) > reader.remaining {
+ dst = dst[:int(reader.remaining)]
+ }
+
+ n, err := reader.src.Read(dst)
+ if n > 0 {
+ reader.remaining -= uint64(n)
+ }
+
+ if errors.Is(err, io.EOF) {
+ if reader.remaining > 0 {
+ return n, io.ErrUnexpectedEOF
+ }
+
+ if n > 0 {
+ return n, nil
+ }
+
+ return 0, io.EOF
+ }
+
+ return n, err //nolint:wrapcheck
+}
diff --git a/internal/iolimit/expect_length_reader_test.go b/internal/iolimit/expect_length_reader_test.go
new file mode 100644
index 00000000..6508e5eb
--- /dev/null
+++ b/internal/iolimit/expect_length_reader_test.go
@@ -0,0 +1,78 @@
+package iolimit_test
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "testing"
+
+ "lindenii.org/go/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)
+ }
+}