diff options
| author | 2026-06-08 09:58:26 +0000 | |
|---|---|---|
| committer | 2026-06-08 09:58:26 +0000 | |
| commit | 71f44dca0b6210fa501e9d8450ee3d7bf5f73347 (patch) | |
| tree | af6af234e9f12d8aa71e59ffb77ae06635df289b | |
| parent | REFATOR: object and object/fetch are done (diff) | |
| signature | No signature | |
internal/iolimit: Add
Might move this to lgo sometime
| -rw-r--r-- | internal/iolimit/capped_capture_writer.go | 56 | ||||
| -rw-r--r-- | internal/iolimit/capped_capture_writer_test.go | 45 | ||||
| -rw-r--r-- | internal/iolimit/doc.go | 6 | ||||
| -rw-r--r-- | internal/iolimit/expect_length_reader.go | 78 | ||||
| -rw-r--r-- | internal/iolimit/expect_length_reader_test.go | 78 |
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) + } +} |
