From a113b7bd62e564b973d4c5ce784aa11b65d2a1e0 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 26 Aug 2026 06:35:04 -0400 Subject: [PATCH] fix(riff): grow a chunk buffer with what arrives Parse sized its buffer from the chunk header, which is four bytes read off the file, so a truncated or malformed WAV declaring a 4 GB data chunk in a 2 kB file got 4 GB from the allocator before the read discovered there was nothing to put in it. The error was always right; the allocation happened first. io.CopyN into a bytes.Buffer is what ID3Chunk beside it has done since #104, and needs nothing new: the reader stays an io.Reader and the buffer grows with what actually arrives. The regression test measures rather than asserts the error, because the error is identical on a build that allocates the gigabyte. Measured on the pre-fix build: 1,073,750,920 bytes of TotalAlloc for a 42-byte container whose data chunk claimed 1 GiB. Closes #216 --- backend/riff/riff.go | 9 +++++--- backend/riff/riff_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/backend/riff/riff.go b/backend/riff/riff.go index e60d265..60e2b79 100644 --- a/backend/riff/riff.go +++ b/backend/riff/riff.go @@ -63,12 +63,15 @@ func Parse(r io.Reader) ([]Chunk, error) { return nil, err } - data := make([]byte, size) - if _, err := io.ReadFull(r, data); err != nil { + // Copied rather than allocated up front, as ID3Chunk does: the + // size is four bytes off the file, so a truncated one is free to + // declare a chunk larger than the whole of itself. + var data bytes.Buffer + if _, err := io.CopyN(&data, r, int64(size)); err != nil { return nil, fmt.Errorf("read chunk data for %q: %w", id, err) } - chunks = append(chunks, Chunk{ID: id, Data: data}) + chunks = append(chunks, Chunk{ID: id, Data: data.Bytes()}) // Odd-length chunks have a padding byte. Lenient: if the // read fails (e.g. EOF), just break rather than error. diff --git a/backend/riff/riff_test.go b/backend/riff/riff_test.go index 19295ff..3c9da6a 100644 --- a/backend/riff/riff_test.go +++ b/backend/riff/riff_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "errors" + "runtime" "testing" "yellowjacket/backend/riff" @@ -209,3 +210,45 @@ func TestParse_ReadsEveryChunkInOrder(t *testing.T) { t.Errorf("odd chunk data: got %q, want %q", chunks[1].Data, "INFOodd") } } + +// A chunk size is four bytes read off the file, so a truncated or +// malformed WAV is free to declare a chunk larger than the whole of +// itself. Parse must grow with what arrives rather than with what was +// claimed. +// +// This measures the allocation instead of the error because the error +// is the same either way: a build sizing its buffer from the header +// reports the truncation correctly, having asked the allocator for a +// gigabyte on the way. Deliberately not parallel — TotalAlloc is +// process-wide, and a test paused beside another one is measuring it +// too. +func TestParse_DoesNotAllocateWhatAChunkClaims(t *testing.T) { + // Large enough that a header-sized buffer is unmistakable, in a + // container of a few dozen bytes. + const declared = 1 << 30 + + var raw bytes.Buffer + + raw.WriteString("RIFF") + _ = binary.Write(&raw, binary.LittleEndian, uint32(declared+12)) + raw.WriteString("WAVE") + raw.WriteString("data") + _ = binary.Write(&raw, binary.LittleEndian, uint32(declared)) + raw.WriteString("and then the file ends") + + var before, after runtime.MemStats + + runtime.GC() + runtime.ReadMemStats(&before) + + if _, err := riff.Parse(bytes.NewReader(raw.Bytes())); err == nil { + t.Fatal("Parse: got nil error for a chunk larger than the file holding it") + } + + runtime.ReadMemStats(&after) + + if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 { + t.Errorf("Parse allocated %d bytes reading a %d-byte file whose chunk header claimed %d", + grew, raw.Len(), declared) + } +} -- 2.54.0