From d910613ea0ec6589ff5c40a6a133572fca34ffb4 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 10:43:59 +0200 Subject: [PATCH 1/2] test: FuzzUnmarshalBinary; audit entry for the decoder UnmarshalBinary (v0.10.0) takes bytes that may come from anywhere, and nothing fuzzed it. The new target requires that a refusal wraps ErrFormat, that nothing panics, that an accepted array re-encodes to no more bytes than it was decoded from (so a few bytes cannot claim a huge array), and that re-encoding is a fixed point. 11 million inputs, no failure. A copy accepting fewer bytes than the shape needs fails it in 0.09 s (6 bytes decoded as 48 bools). govulncheck at go1.27.2 with go-fft v0.23.0: none. SECURITY.md gains the guarantee and the audit entry. Co-Authored-By: Claude Opus 5.5 --- SECURITY.md | 18 +++++++++++ marshal_fuzz_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 marshal_fuzz_test.go diff --git a/SECURITY.md b/SECURITY.md index cd9dcd5..87d4cb5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,12 +51,30 @@ bounds checks do not cover, so they are held to the following: test from NumPy's definitions (Python's `slice.indices`, the broadcasting rule, ...). The library must reject exactly the calls the oracle rejects and never panic. +- **Decoding takes bytes from anywhere.** `UnmarshalBinary` refuses, with an + error wrapping `ErrFormat`, any input whose shape the bytes that follow do + not account for exactly, before it allocates; an array it accepts holds no + more than it was given. `FuzzUnmarshalBinary` (`marshal_fuzz_test.go`) + requires that, that it never panics, and that re-encoding is a fixed point. The minimum Go version (`go` in `go.mod`) is kept on a release that fixes the standard library's known vulnerabilities; `govulncheck ./...` reports none. ## Audit log +**2026-10-11 (after v0.11.0, tests only).** v0.10.0 added `UnmarshalBinary`, +whose input may come from a file, a notebook or the network; no entry covered +it. It checks the magic and the dtype, bounds the rank at 64 and each +dimension at `maxSize`, validates the shape, and compares the data length with +the element count by dividing, not multiplying, so a `complex128` shape cannot +overflow the comparison. Only then does it allocate, so the allocation is the +size of the input. `FuzzUnmarshalBinary` holds it to that, never panicking +and re-encoding to a fixed point. It ran 11 million inputs on an M-series Mac +with no failure. A copy that accepted fewer bytes than the shape needs failed +it in 0.09 s (6 bytes decoded as 48 bools), so the fuzzer can see the defect +it guards against. `govulncheck ./...` at go1.27.2 with go-fft v0.23.0 reports +none. + **2026-10-09 (v0.9.1).** New s390x `maxVXE`/`minVXE` kernels, gated on AT_HWCAP VXRS_EXT (z14 and later), each behind its wrapper: from eight elements up, with no pointer arithmetic beyond `len(a)`. On the real z17 they diff --git a/marshal_fuzz_test.go b/marshal_fuzz_test.go new file mode 100644 index 0000000..7ee2e3d --- /dev/null +++ b/marshal_fuzz_test.go @@ -0,0 +1,72 @@ +package ndarray + +import ( + "bytes" + "errors" + "testing" +) + +// FuzzUnmarshalBinary feeds arbitrary bytes to UnmarshalBinary, which its +// documentation says may come from anywhere. It must never panic, and: +// +// - a refusal wraps ErrFormat, so a caller can tell bad input from a bug; +// - an accepted array re-encodes to at most as many bytes as it was decoded +// from (the encoding is minimal), so what it holds is bounded by what it +// was given: a few bytes cannot claim a huge array; +// - re-encoding is a fixed point: decoding the re-encoding gives the same +// dtype, shape and bytes. +// +// The seed corpus runs as an ordinary test; `go test -fuzz +// FuzzUnmarshalBinary` explores. +func FuzzUnmarshalBinary(f *testing.F) { + for _, dt := range allDTypes() { + for _, shape := range [][]int{{}, {0}, {3}, {2, 3}, {0, 1 << 40}} { + a, err := ZerosOf(dt, shape...) + if err != nil { + f.Fatal(err) + } + b, err := a.MarshalBinary() + if err != nil { + f.Fatal(err) + } + f.Add(b) + } + } + f.Add([]byte{}) + f.Add([]byte("ND\x01")) + f.Add([]byte("ND\x01\xff")) + // A rank and dimensions as large as uvarints can say. + f.Add([]byte("ND\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01")) + f.Add([]byte("ND\x01\x00\x02\xff\xff\xff\xff\x0f\xff\xff\xff\xff\x0f")) + // A non-minimal uvarint for a rank of 1, and a bool byte that is not 0 or 1. + f.Add([]byte("ND\x01\x00\x81\x00\x01\x07")) + f.Fuzz(func(t *testing.T, data []byte) { + var a Array + if err := a.UnmarshalBinary(data); err != nil { + if !errors.Is(err, ErrFormat) { + t.Fatalf("refusal does not wrap ErrFormat: %v", err) + } + return + } + enc, err := a.MarshalBinary() + if err != nil { + t.Fatalf("an accepted array does not re-encode: %v", err) + } + if len(enc) > len(data) { + t.Fatalf("%d input bytes decoded to an array of %d encoded bytes (%v %v)", + len(data), len(enc), a.DType(), a.Shape()) + } + var b Array + if err := b.UnmarshalBinary(enc); err != nil { + t.Fatalf("the re-encoding is refused: %v", err) + } + again, err := b.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if b.DType() != a.DType() || !equalInts(b.Shape(), a.Shape()) || !bytes.Equal(again, enc) { + t.Fatalf("re-encoding is not a fixed point: %v %v -> %v %v", + a.DType(), a.Shape(), b.DType(), b.Shape()) + } + }) +} From 75484fe7078b4e1c69808e2820dbe18300396ad8 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 11:01:27 +0200 Subject: [PATCH 2/2] test: FuzzUnmarshalBinary seeds a huge axis with maxSize, not 1<<40 1<<40 does not fit the int of 386, arm and the other 32-bit targets, so the test did not compile there. maxSize is the package's own bound on every architecture. Compiled for linux/386, arm and mipsle, and run on linux/386. Co-Authored-By: Claude Opus 5.5 --- marshal_fuzz_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/marshal_fuzz_test.go b/marshal_fuzz_test.go index 7ee2e3d..6907cee 100644 --- a/marshal_fuzz_test.go +++ b/marshal_fuzz_test.go @@ -20,7 +20,7 @@ import ( // FuzzUnmarshalBinary` explores. func FuzzUnmarshalBinary(f *testing.F) { for _, dt := range allDTypes() { - for _, shape := range [][]int{{}, {0}, {3}, {2, 3}, {0, 1 << 40}} { + for _, shape := range [][]int{{}, {0}, {3}, {2, 3}, {0, maxSize}} { a, err := ZerosOf(dt, shape...) if err != nil { f.Fatal(err)