Files
tensor/io/hostile_test.go
T

268 lines
9.8 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package io
import (
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
)
// Hostile inputs: a data file is untrusted input, so every size field
// the header carries must be checked against the bytes actually
// present before it is used. These cases pin the contract that a
// malformed file is an error, never a panic and never an allocation
// sized by the header alone.
// hostileNCName writes a 4-byte-padded NetCDF name.
func hostileNCName(b []byte, name string) []byte {
b = binary.BigEndian.AppendUint32(b, uint32(len(name)))
b = append(b, name...)
if pad := (4 - len(name)%4) % 4; pad != 0 {
b = append(b, make([]byte, pad)...)
}
return b
}
// ncHostileHeader builds a minimal CDF-1 file: magic, numrecs, a
// dimension list with the given lengths, an absent attribute list, and
// one NC_DOUBLE variable over those dimensions. Nothing follows the
// header, so any declared data is truncated by construction.
func ncHostileHeader(names []string, lengths []uint32) []byte {
b := []byte{'C', 'D', 'F', 1}
b = binary.BigEndian.AppendUint32(b, 0) // numrecs
b = binary.BigEndian.AppendUint32(b, ncTagDimension)
b = binary.BigEndian.AppendUint32(b, uint32(len(lengths)))
for i, l := range lengths {
b = hostileNCName(b, names[i])
b = binary.BigEndian.AppendUint32(b, l)
}
b = binary.BigEndian.AppendUint32(b, 0) // absent attribute list
b = binary.BigEndian.AppendUint32(b, 0)
b = binary.BigEndian.AppendUint32(b, ncTagVariable)
b = binary.BigEndian.AppendUint32(b, 1)
b = hostileNCName(b, "v")
b = binary.BigEndian.AppendUint32(b, uint32(len(lengths)))
for i := range lengths {
b = binary.BigEndian.AppendUint32(b, uint32(i))
}
b = binary.BigEndian.AppendUint32(b, 0) // absent variable attributes
b = binary.BigEndian.AppendUint32(b, 0)
b = binary.BigEndian.AppendUint32(b, ncTypeDouble)
b = binary.BigEndian.AppendUint32(b, 0) // vsize
b = binary.BigEndian.AppendUint32(b, 0) // begin
return b
}
// writeHostile drops the bytes into a temp file and returns the path.
func writeHostile(t *testing.T, name string, data []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
return path
}
// TestLoadNetCDFHostileCounts covers the three ways a declared count
// can be used to demand memory the file cannot back: a product that
// wraps to a small positive number, a product that wraps negative, and
// record counts that are simply larger than the file.
func TestLoadNetCDFHostileCounts(t *testing.T) {
cases := []struct {
name string
names []string
lengths []uint32
}{
{
// 2^21 * 2^20 * 2^20 = 2^61, which times the 8-byte
// element width wraps to zero in a 64-bit multiply.
name: "product wraps to zero",
names: []string{"a", "b", "c"},
lengths: []uint32{1 << 21, 1 << 20, 1 << 20},
},
{
// 4294967295 squared wraps to a negative product.
name: "product wraps negative",
names: []string{"a", "b"},
lengths: []uint32{4294967295, 4294967295},
},
{
name: "one dimension longer than the file",
names: []string{"a"},
lengths: []uint32{1 << 30},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := writeHostile(t, "hostile.nc", ncHostileHeader(tc.names, tc.lengths))
if _, _, _, err := LoadNetCDF(path); err == nil {
t.Fatal("expected an error for a header whose data cannot fit the file")
}
})
}
}
// TestLoadNetCDFHostileRecordCounts pins the list caps: a header that
// declares millions of dimensions, attributes or variables in a file
// of a few bytes must fail before the list is allocated.
func TestLoadNetCDFHostileRecordCounts(t *testing.T) {
const declared = 2000000
dimCount := []byte{'C', 'D', 'F', 1}
dimCount = binary.BigEndian.AppendUint32(dimCount, 0)
dimCount = binary.BigEndian.AppendUint32(dimCount, ncTagDimension)
dimCount = binary.BigEndian.AppendUint32(dimCount, declared)
attrCount := []byte{'C', 'D', 'F', 1}
attrCount = binary.BigEndian.AppendUint32(attrCount, 0)
attrCount = binary.BigEndian.AppendUint32(attrCount, 0) // absent dimensions
attrCount = binary.BigEndian.AppendUint32(attrCount, 0)
attrCount = binary.BigEndian.AppendUint32(attrCount, ncTagAttribute)
attrCount = binary.BigEndian.AppendUint32(attrCount, declared)
varCount := []byte{'C', 'D', 'F', 1}
varCount = binary.BigEndian.AppendUint32(varCount, 0)
varCount = binary.BigEndian.AppendUint32(varCount, 0) // absent dimensions
varCount = binary.BigEndian.AppendUint32(varCount, 0)
varCount = binary.BigEndian.AppendUint32(varCount, 0) // absent attributes
varCount = binary.BigEndian.AppendUint32(varCount, 0)
varCount = binary.BigEndian.AppendUint32(varCount, ncTagVariable)
varCount = binary.BigEndian.AppendUint32(varCount, declared)
cases := []struct {
name string
data []byte
}{
{"dimensions", dimCount},
{"attributes", attrCount},
{"variables", varCount},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := writeHostile(t, "counts.nc", tc.data)
_, _, _, err := LoadNetCDF(path)
if err == nil {
t.Fatalf("a %d-byte file declaring %d %s must be refused", len(tc.data), declared, tc.name)
}
if !strings.Contains(err.Error(), "remaining bytes") {
t.Fatalf("error = %v, want the declared-count bound", err)
}
})
}
}
// TestLoadNetCDFTruncatedVariable pins the data-block check: a header
// that points its variable past the end of the file fails without
// reading beyond it.
func TestLoadNetCDFTruncatedVariable(t *testing.T) {
data := ncHostileHeader([]string{"a"}, []uint32{4})
binary.BigEndian.PutUint32(data[len(data)-4:], 1<<30) // begin past EOF
path := writeHostile(t, "short.nc", data)
if _, _, _, err := LoadNetCDF(path); err == nil {
t.Fatal("expected an error for a variable whose data lies past the end of the file")
}
}
// TestLoadFITSTableTruncatedHostile pins the table data guard: a file
// that ends inside the header block has no data at all, whether or not
// the column forms give it a row size to divide by.
func TestLoadFITSTableTruncatedHostile(t *testing.T) {
// No data block follows the header: the cards stop at 640 bytes.
header := func(form string) []byte {
var b []byte
for _, body := range []string{
"XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 2",
"NAXIS1 = 4", "NAXIS2 = 2", "TFIELDS = 1",
"TFORM1 = '" + form + strings.Repeat(" ", 8-len(form)) + "'", "END",
} {
b = append(b, card(body)...)
}
return b
}
for _, form := range []string{"X", "D"} {
t.Run(form, func(t *testing.T) {
data := header(form)
if len(data) != 640 {
t.Fatalf("the test header is %d bytes, want 640", len(data))
}
path := writeHostile(t, "trunc.fits", data)
if _, err := LoadFITSTable(path); err == nil {
t.Fatal("expected an error for a table whose data block is missing")
}
})
}
}
// TestLoadNetCDFUnusedLongDimension pins the other side of the bound: a
// header may declare a dimension longer than the data any variable
// uses, because the format allows it, so the reader must accept the
// file rather than refuse a legal one.
func TestLoadNetCDFUnusedLongDimension(t *testing.T) {
// One dimension of a million elements, one variable using none of
// it: the count product is 1, so nothing is read.
var b []byte
b = append(b, 'C', 'D', 'F', 1)
b = binary.BigEndian.AppendUint32(b, 0) // numrecs
b = binary.BigEndian.AppendUint32(b, 10) // NC_DIMENSION
b = binary.BigEndian.AppendUint32(b, 1)
b = hostileNCName(b, "big")
b = binary.BigEndian.AppendUint32(b, 1<<20)
b = binary.BigEndian.AppendUint32(b, 0) // absent attributes
b = binary.BigEndian.AppendUint32(b, 0)
b = binary.BigEndian.AppendUint32(b, 11) // NC_VARIABLE
b = binary.BigEndian.AppendUint32(b, 1)
b = hostileNCName(b, "scalar")
b = binary.BigEndian.AppendUint32(b, 0) // rank 0
b = binary.BigEndian.AppendUint32(b, 0) // absent attributes
b = binary.BigEndian.AppendUint32(b, 0)
b = binary.BigEndian.AppendUint32(b, 6) // NC_DOUBLE
b = binary.BigEndian.AppendUint32(b, 8) // vsize
b = binary.BigEndian.AppendUint32(b, 0) // begin
b = append(b, 0, 0, 0, 0, 0, 0, 0, 0) // one double at offset 0
path := writeHostile(t, "unused.nc", b)
dims, vars, _, err := LoadNetCDF(path)
if err != nil {
t.Fatalf("LoadNetCDF refused a legal file: %v", err)
}
if len(dims) != 1 || dims[0].Length != 1<<20 {
t.Fatalf("dims = %+v, want one dimension of 2^20", dims)
}
if len(vars) != 1 || vars[0].Values.Len() != 1 {
t.Fatalf("vars = %+v, want one scalar", vars)
}
}
// TestLoadHDF5ChunkTreeCycle pins the visited set on the chunk B-tree
// walk: an inner node that lists itself among its children must be
// refused. Without the set the walk multiplies into entries^depth node
// visits before the depth guard can fire, so a few hundred crafted
// bytes never terminate.
func TestLoadHDF5ChunkTreeCycle(t *testing.T) {
const entries = 512
rank := 1
keySize := 8 + 8*(rank+1)
entrySize := keySize + 8
buf := make([]byte, 24+entries*entrySize)
copy(buf, []byte("TREE"))
buf[4] = 1 // a chunk node
buf[5] = 1 // inner level: every child is recursed, not placed
binary.LittleEndian.PutUint16(buf[6:], entries)
for i := range entries {
p := 24 + i*entrySize
binary.LittleEndian.PutUint32(buf[p:], 16) // chunk size
binary.LittleEndian.PutUint32(buf[p+4:], 0) // filter mask
// The key offsets stay zero; the child address points at
// this very node.
binary.LittleEndian.PutUint64(buf[p+keySize:], 0)
}
f := &hdf5File{data: buf, offSize: 8, lenSize: 8}
place := func(uint64, []uint64, uint32, int) error { return nil }
if err := f.chunkTree(0, hdf5Layout{}, rank, map[uint64]bool{}, place, 0); err == nil || !strings.Contains(err.Error(), "revisits") {
t.Fatalf("chunkTree on a self-referencing node: %v", err)
}
}