Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions marshal_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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, maxSize}} {
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())
}
})
}
Loading