check / check (push) Waiting to run
Before decoding the manifest, the parser adds up what decoding sets aside for each file entry, hash, timestamp and MIME type, however short its encoding, and refuses the manifest once that passes 8 times the decompressed size; manifests mfer writes come to at most about 7.15 times. Empty entries decoded to about 50 times their size: under 1 KB of manifest allocated about 500 MB. Fields the decoder does not know are dropped; kept, they took up to 5 times more. A test refuses entries counted just over 8 times and loads them just under. The fuzz ceiling rises from 16 to 20 times the input and decompressed data; seeds of empty entries and of empty hashes fail it without the fix. Model: opus-5-5
91 lines
3.6 KiB
Go
91 lines
3.6 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// FuzzNewManifestFromReader feeds arbitrary bytes to the manifest parser.
|
|
// `make test` runs it on the seed corpus in
|
|
// testdata/fuzz/FuzzNewManifestFromReader; `make fuzz` searches for new
|
|
// inputs.
|
|
//
|
|
// For every input the parser must return a manifest or an error, not both
|
|
// and not neither, and must not allocate more than a fixed multiple of its
|
|
// input and of the decompressed data it may read, plus room for the
|
|
// decoder's window buffers. A panic or a hang fails the test on its own.
|
|
func FuzzNewManifestFromReader(f *testing.F) {
|
|
// A signed manifest makes the parser write the key and signature to a
|
|
// temporary directory and run gpg on them. With gpg off the PATH and
|
|
// temporary files kept in the test's own directory, no process is
|
|
// started and nothing is written elsewhere; such input ends in an
|
|
// error instead.
|
|
f.Setenv("PATH", "")
|
|
f.Setenv("TMPDIR", f.TempDir())
|
|
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
var before, after runtime.MemStats
|
|
|
|
runtime.ReadMemStats(&before)
|
|
|
|
m, err := NewManifestFromReader(bytes.NewReader(data))
|
|
|
|
runtime.ReadMemStats(&after)
|
|
|
|
if (m == nil) == (err == nil) {
|
|
t.Fatalf("got manifest %p and error %v, want exactly one", m, err)
|
|
}
|
|
|
|
// The parser reads at most the declared size plus one byte of
|
|
// decompressed data, and never more than MaxDecompressedSize.
|
|
decompressed := uint64(MaxDecompressedSize)
|
|
|
|
outer := new(MFFileOuter)
|
|
if validateMagic(data) &&
|
|
proto.Unmarshal(data[len(MAGIC):], outer) == nil {
|
|
size := outer.GetSize()
|
|
if size > 0 && size < MaxDecompressedSize {
|
|
decompressed = uint64(size) + 1
|
|
}
|
|
}
|
|
|
|
// It also keeps a few copies of its input. Buffers grow by
|
|
// copying, so reaching those sizes allocates up to about six times
|
|
// them in total. Decoding the decompressed data takes up to
|
|
// maxDecodedGrowth times its size for file entries, hashes,
|
|
// timestamps and MIME types, and drops fields it does not know.
|
|
// The strings and bytes it copies out of it, such as many one-byte
|
|
// values in one hash, take up to about five times more under the
|
|
// race detector, which pads every small copy to 16 bytes, and about
|
|
// half that without it. Twenty times the input and the
|
|
// decompressed data leaves room for all of that.
|
|
//
|
|
// The decoder also sets aside a new buffer of one to two times the
|
|
// window for each frame that asks for a larger window than the
|
|
// frames before it, and refuses windows above zstdWindowSize. A
|
|
// frame that gives its content size instead of a window has that
|
|
// size as its window, refused above zstdWindowSize like any other.
|
|
// Frames asking for every window size up to that make it set aside
|
|
// about 16 times zstdWindowSize in total; 24 times leaves room.
|
|
//
|
|
// The seed whose frame claims 8 GiB fails if the decoder sets that
|
|
// size aside; the seed whose frames ask for ever larger windows
|
|
// fails if the decoder accepts windows of twice zstdWindowSize; the
|
|
// seed whose two frames together exceed MaxDecompressedSize fails
|
|
// if the decoder decodes them in full instead of stopping at the
|
|
// declared size; the seeds of empty file entries and of a file
|
|
// entry of empty hashes fail if the parser decodes them.
|
|
limit := 20*(uint64(len(data))+decompressed) + 24*zstdWindowSize
|
|
|
|
allocated := after.TotalAlloc - before.TotalAlloc
|
|
if allocated > limit {
|
|
t.Fatalf("allocated %d bytes for %d bytes of input, limit %d",
|
|
allocated, len(data), limit)
|
|
}
|
|
})
|
|
}
|