Files
tensor/io/hdf5_structure_pins_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

528 lines
20 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package io
import (
"encoding/binary"
"math"
"slices"
"strings"
"testing"
"time"
)
// Regression pins: continuation links the reader sliced past the
// end of, a local heap header sized by the wrong field, the diamond of
// hard links that multiplied the walk, the read budget that only
// counted value bytes, the B-tree sizes pinned to eight-byte addresses,
// negative FITS column counts, the NAXIS prefix that swallowed user
// keywords, and the version 2 header features no test exercised.
// h5SizedHostileFile returns an n-byte HDF5 file with the signature and
// a version 0 superblock of the given address and length sizes whose
// root object header sits at rootAt. h5HostileFile is the 8/8 case;
// the hostile files here need the others.
func h5SizedHostileFile(n, offSize, lenSize int, rootAt uint64) []byte {
f := make([]byte, n)
copy(f, hdf5Magic)
f[8] = 0 // superblock version 0
f[13] = byte(offSize)
f[14] = byte(lenSize)
// Four addresses of offSize bytes from 24: base, free space, end of
// file, driver information.
putAddr := func(at int, v uint64) {
switch offSize {
case 4:
binary.LittleEndian.PutUint32(f[at:], uint32(v))
case 8:
binary.LittleEndian.PutUint64(f[at:], v)
}
}
putAddr(24, 0)
putAddr(28, math.MaxUint64)
putAddr(32, uint64(n))
putAddr(36, math.MaxUint64)
// The root symbol table entry: a link name offset of the length
// size, then the object header address of the address size.
entry := 24 + 4*offSize
putAddr(entry+lenSize, rootAt)
return f
}
// h5HardLink renders a version 1 hard-link message body: a one-byte
// name length, the name and the object header address.
func h5HardLink(name string, addr uint64) []byte {
b := append([]byte{1, 0, byte(len(name))}, name...)
return binary.LittleEndian.AppendUint64(b, addr)
}
// h5V3Superblock returns an n-byte file with a version 3 superblock
// (eight-byte addresses and lengths, the whole block under a lookup3
// checksum) whose root object header sits at rootAt.
func h5V3Superblock(n int, rootAt uint64) []byte {
f := make([]byte, n)
copy(f, hdf5Magic)
f[8] = 3
f[9] = 8 // address size
f[10] = 8 // length size
f[11] = 0 // consistency flags
binary.LittleEndian.PutUint64(f[12:], 0) // base address
binary.LittleEndian.PutUint64(f[20:], math.MaxUint64) // no extension
binary.LittleEndian.PutUint64(f[28:], uint64(n)) // end of file
binary.LittleEndian.PutUint64(f[36:], rootAt)
binary.LittleEndian.PutUint32(f[44:], hdf5Lookup3(f[:44]))
return f
}
// h5WriteOHDRv2 writes a version 2 object header at at whose first
// message region is chunk, under the given flag byte, computing and
// storing the lookup3 checksum, and returns the offset just past the
// header. Flag-selected prefix fields are written as zeros.
func h5WriteOHDRv2(f []byte, at int, flags byte, chunk []byte) int {
copy(f[at:], hdf5ObjHdr2)
f[at+4] = 2
f[at+5] = flags
p := at + 6
if flags&0x20 != 0 {
p += 16 // access, modification, change and birth times
}
if flags&0x10 != 0 {
p += 4 // max compact and min dense attribute counts
}
width := 1 << (flags & 0x03)
switch width {
case 1:
f[p] = byte(len(chunk))
case 2:
binary.LittleEndian.PutUint16(f[p:], uint16(len(chunk)))
case 4:
binary.LittleEndian.PutUint32(f[p:], uint32(len(chunk)))
case 8:
binary.LittleEndian.PutUint64(f[p:], uint64(len(chunk)))
}
p += width
copy(f[p:], chunk)
p += len(chunk)
binary.LittleEndian.PutUint32(f[p:], hdf5Lookup3(f[at:p]))
return p + 4
}
// h5V2Msg renders one version 2 object header message: a type byte, a
// two-byte size and a flag byte, widened by a two-byte creation order
// when the header tracks it, then the body.
func h5V2Msg(typ byte, order uint16, body []byte, ordered bool) []byte {
head := 4
if ordered {
head = 6
}
m := make([]byte, head+len(body))
m[0] = typ
binary.LittleEndian.PutUint16(m[1:], uint16(len(body)))
if ordered {
binary.LittleEndian.PutUint16(m[4:], order)
}
copy(m[head:], body)
return m
}
// h5V2Dataspace renders a version 2 dataspace message body: rank one,
// the given extent in eight bytes.
func h5V2Dataspace(dim uint64) []byte {
b := make([]byte, 12)
b[0] = 2 // version
b[1] = 1 // rank
binary.LittleEndian.PutUint64(b[4:], dim)
return b
}
// TestLoadHDF5ShortV1Continuation pins a minimal hostile
// file: a continuation block that ends right after its eight-byte
// message header carries a zero-size continuation message, and the
// reader used to slice the link's offset and length out of bytes past
// the block (a [16:8] panic out of LoadHDF5). It must be a named
// error.
func TestLoadHDF5ShortV1Continuation(t *testing.T) {
f := h5HostileFile(512)
h5ObjectHeader(f, 96,
h5Msg{hdf5MsgContinuation, h5Link(136, 8)},
)
// The block: exactly eight bytes, one continuation header, no body.
binary.LittleEndian.PutUint16(f[136:], hdf5MsgContinuation)
binary.LittleEndian.PutUint16(f[138:], 0)
_, err := LoadHDF5(writeHostile(t, "v1short.h5", f))
if err == nil {
t.Fatal("LoadHDF5 accepted a continuation message with no link body")
}
if !strings.Contains(err.Error(), "shorter than an offset and a length") {
t.Fatalf("error = %v, want the short-link refusal", err)
}
}
// TestLoadHDF5ShortV2Continuation pins the version 2 twin: a header
// whose whole message region is one continuation message of size zero
// panicked the same way ([12:4]), because the stream read the link's
// offset and length past the region.
func TestLoadHDF5ShortV2Continuation(t *testing.T) {
f := h5V3Superblock(512, 96)
chunk := []byte{hdf5MsgContinuation, 0, 0, 0} // type 16, size 0, flags 0
h5WriteOHDRv2(f, 96, 0, chunk)
_, err := LoadHDF5(writeHostile(t, "v2short.h5", f))
if err == nil {
t.Fatal("LoadHDF5 accepted a version 2 continuation message with no link body")
}
if !strings.Contains(err.Error(), "shorter than an offset and a length") {
t.Fatalf("error = %v, want the short-link refusal", err)
}
}
// TestLoadHDF5LocalHeapMixedSizes pins the local heap header size: the
// header holds two length-size fields and then one address-size field,
// but the reader sized it with three address-size fields, so a 4/8 file
// (twenty-byte header) was sliced at [24:] and panicked. A heap whose
// header lies about nothing must still be refused cleanly when what it
// points at is absent.
func TestLoadHDF5LocalHeapMixedSizes(t *testing.T) {
f := h5SizedHostileFile(512, 4, 8, 96)
body := make([]byte, 2*4)
binary.LittleEndian.PutUint32(body, math.MaxUint32) // B-tree: undefined
binary.LittleEndian.PutUint32(body[4:], 256) // local heap at 256
h5ObjectHeader(f, 96, h5Msg{hdf5MsgSymbolTable, body})
copy(f[256:], hdf5LocalHeap)
f[260] = 1 // version
_, err := LoadHDF5(writeHostile(t, "heap48.h5", f))
if err != nil && strings.Contains(err.Error(), "runtime error") {
t.Fatalf("the 4/8 local heap panicked: %v", err)
}
_ = err // any named refusal is fine; the panic is the defect
}
// TestWalkDiamondReadsOnce pins the diamond: two names on one group
// used to walk the group twice, and a diamond of depth d walked it 2^d
// times, which stopped the reader for hours on a file of a kilobyte.
// The object is read once, under the first path the traversal reaches,
// and a link that closes a cycle along the current path is still an
// error.
func TestWalkDiamondReadsOnce(t *testing.T) {
guard := time.AfterFunc(20*time.Second, func() { panic("diamond walk did not return") })
defer guard.Stop()
const childAt, datasetAt, dataAt = 256, 384, 512
f := h5HostileFile(576)
h5ObjectHeader(f, 96,
h5Msg{hdf5MsgLink, h5HardLink("a", childAt)},
h5Msg{hdf5MsgLink, h5HardLink("b", childAt)},
)
h5ObjectHeader(f, childAt,
h5Msg{hdf5MsgLink, h5HardLink("d", datasetAt)},
)
h5ObjectHeader(f, datasetAt,
h5Msg{hdf5MsgDataspace, h5Dataspace(1)},
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
h5Msg{hdf5MsgDataLayout, h5ContiguousLayout(dataAt, 8)},
)
binary.LittleEndian.PutUint64(f[dataAt:], math.Float64bits(2.5))
sets, err := LoadHDF5(writeHostile(t, "diamond.h5", f))
if err != nil {
t.Fatalf("LoadHDF5 on a diamond of hard links: %v", err)
}
if len(sets) != 1 {
t.Fatalf("datasets = %d, want the single dataset once", len(sets))
}
// Deterministic: the links of a group are visited in file order, so
// the first path wins and the listing is stable.
if sets[0].Path != "/a/d" {
t.Fatalf("path = %q, want /a/d, the first path to the object", sets[0].Path)
}
if got := sets[0].Values.FloatAt(0); got != 2.5 {
t.Fatalf("value = %v, want 2.5", got)
}
t.Run("cycle", func(t *testing.T) {
// The skip must never swallow a cycle: an object that closes a
// loop along the current path is refused, not skipped.
if _, err := LoadHDF5(writeHostile(t, "diamond-cycle.h5", selfLink())); err == nil || !strings.Contains(err.Error(), "hard-link cycle") {
t.Fatalf("a hard-link cycle: err = %v, want the cycle refusal", err)
}
})
}
// TestHDF5BudgetChargesDatasetEnvelope pins the aggregate budget: it
// used to deduct only the value bytes, so millions of empty datasets
// would allocate their result structures, paths and attribute maps
// without ever touching the budget. The walk charges a fixed envelope
// plus the dataset's path beside the values, so a budget below one
// envelope refuses even a file of empty datasets.
func TestHDF5BudgetChargesDatasetEnvelope(t *testing.T) {
const dataAt, n = 448, 512
raw := h5HostileFile(n)
h5ObjectHeader(raw, 96,
h5Msg{hdf5MsgDataspace, h5Dataspace(1)},
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
h5Msg{hdf5MsgDataLayout, h5ContiguousLayout(dataAt, 8)},
)
binary.LittleEndian.PutUint64(raw[dataAt:], math.Float64bits(2.5))
// The file itself is legal: at the default budget it loads.
sets, err := LoadHDF5(writeHostile(t, "envelope.h5", raw))
if err != nil || len(sets) != 1 {
t.Fatalf("LoadHDF5 at the default budget: %d datasets, err = %v", len(sets), err)
}
// Below one envelope the same file refuses: the envelope plus the
// path is charged before any allocation happens.
f, err := newHDF5File(raw)
if err != nil {
t.Fatalf("newHDF5File: %v", err)
}
st := newHDF5WalkState()
st.budget = hdf5DatasetEnvelope / 2
var out []HDF5Dataset
if err := f.walk(f.rootAddress, st, &out); err == nil || !strings.Contains(err.Error(), "budget") {
t.Fatalf("walk with a budget below one envelope: err = %v, want the budget refusal", err)
}
}
// TestLoadHDF5SymbolTable4of4 pins the four-byte-address layout of the
// group structures: the version 1 B-tree header is 8+2*offSize wide
// (not 24), a symbol node entry is lenSize+offSize+24 (not 40) and the
// local heap header is 8+2*lenSize+offSize. A legal 4/4 file with a
// symbol-table group of two links used to die with "no symbol table
// node at 0"; the 8/8 fixtures are untouched by the arithmetic.
func TestLoadHDF5SymbolTable4of4(t *testing.T) {
const (
treeAt = 200
snodAt = 240
heapAt = 320
segAt = 352
objA = 384
objB = 512
dataA = 640
dataB = 648
)
f := h5SizedHostileFile(1024, 4, 4, 96)
body := make([]byte, 8)
binary.LittleEndian.PutUint32(body, treeAt)
binary.LittleEndian.PutUint32(body[4:], heapAt)
h5ObjectHeader(f, 96, h5Msg{hdf5MsgSymbolTable, body})
// The group B-tree: a sixteen-byte header (signature, type, level,
// one entry, two sibling addresses), key0 of four bytes, the child
// address, one trailing key.
copy(f[treeAt:], hdf5Tree)
f[treeAt+4] = 0 // group
f[treeAt+5] = 0 // leaf
binary.LittleEndian.PutUint16(f[treeAt+6:], 1)
binary.LittleEndian.PutUint32(f[treeAt+8:], math.MaxUint32)
binary.LittleEndian.PutUint32(f[treeAt+12:], math.MaxUint32)
binary.LittleEndian.PutUint32(f[treeAt+16:], 0) // key0
binary.LittleEndian.PutUint32(f[treeAt+20:], snodAt) // child
binary.LittleEndian.PutUint32(f[treeAt+24:], 4) // trailing key
// The symbol node: two entries of 32 bytes (heap offset, object
// address, cache type, reserved, scratch pad).
copy(f[snodAt:], hdf5SymbolNode)
f[snodAt+4] = 1
binary.LittleEndian.PutUint16(f[snodAt+6:], 2)
binary.LittleEndian.PutUint32(f[snodAt+8:], 0) // "a" at heap offset 0
binary.LittleEndian.PutUint32(f[snodAt+12:], objA)
binary.LittleEndian.PutUint32(f[snodAt+40:], 2) // "b" at heap offset 2
binary.LittleEndian.PutUint32(f[snodAt+44:], objB)
// The local heap: a twenty-byte header (signature, version, data
// segment size, free-list head, data segment address).
copy(f[heapAt:], hdf5LocalHeap)
f[heapAt+4] = 1
binary.LittleEndian.PutUint32(f[heapAt+8:], 8)
binary.LittleEndian.PutUint32(f[heapAt+12:], math.MaxUint32)
binary.LittleEndian.PutUint32(f[heapAt+16:], segAt)
copy(f[segAt:], "a\x00b\x00\x00\x00\x00\x00")
h5ObjectHeader(f, objA,
h5Msg{hdf5MsgDataspace, h5Dataspace(1)},
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
h5Msg{hdf5MsgDataLayout, h5ContiguousLayout(dataA, 8)},
)
h5ObjectHeader(f, objB,
h5Msg{hdf5MsgDataspace, h5Dataspace(1)},
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
h5Msg{hdf5MsgDataLayout, h5ContiguousLayout(dataB, 8)},
)
binary.LittleEndian.PutUint64(f[dataA:], math.Float64bits(4.5))
binary.LittleEndian.PutUint64(f[dataB:], math.Float64bits(-2.5))
sets, err := LoadHDF5(writeHostile(t, "snod44.h5", f))
if err != nil {
t.Fatalf("LoadHDF5 refused a legal 4/4 symbol-table file: %v", err)
}
if len(sets) != 2 {
t.Fatalf("datasets = %d, want 2", len(sets))
}
if sets[0].Path != "/a" || sets[1].Path != "/b" {
t.Fatalf("paths = %q, %q, want /a and /b", sets[0].Path, sets[1].Path)
}
if got := sets[0].Values.FloatAt(0); got != 4.5 {
t.Fatalf("/a = %v, want 4.5", got)
}
if got := sets[1].Values.FloatAt(0); got != -2.5 {
t.Fatalf("/b = %v, want -2.5", got)
}
}
// TestLoadHDF5ContinuationChainDepth pins the version 1 depth guard: a
// chain of continuation blocks one longer than the cap must be refused
// like the version 2 walk refuses its own, not walked to the end.
func TestLoadHDF5ContinuationChainDepth(t *testing.T) {
const first = 224
blocks := hdf5MaxHeaderBlocks + 2
f := h5HostileFile(first + blocks*24)
h5ObjectHeader(f, 96,
h5Msg{hdf5MsgContinuation, h5Link(first, 24)},
)
for k := range blocks {
at := first + k*24
binary.LittleEndian.PutUint16(f[at:], hdf5MsgContinuation)
if k == blocks-1 {
// The tail block carries nothing: the walk must be refused
// on reaching it, not on reading it.
binary.LittleEndian.PutUint16(f[at+2:], 0)
continue
}
binary.LittleEndian.PutUint16(f[at+2:], 16)
binary.LittleEndian.PutUint64(f[at+8:], uint64(at+24))
binary.LittleEndian.PutUint64(f[at+16:], 24)
}
_, err := LoadHDF5(writeHostile(t, "chain.h5", f))
if err == nil {
t.Fatalf("LoadHDF5 walked a chain of %d continuation blocks", blocks)
}
if !strings.Contains(err.Error(), "continuation blocks") {
t.Fatalf("error = %v, want the chain-depth refusal", err)
}
}
// TestLoadHDF5V2HeaderFlags covers the version 2 header features the
// reference fixture does not carry: the four times (0x20), the attribute
// counts (0x10) and creation order tracking (0x04, which widens every
// message by its order field). A header with all three set must read
// like any other.
func TestLoadHDF5V2HeaderFlags(t *testing.T) {
const dataAt = 320
f := h5V3Superblock(512, 96)
msgs := slices.Concat(
h5V2Msg(hdf5MsgDataspace, 1, h5V2Dataspace(2), true),
h5V2Msg(hdf5MsgDatatype, 2, h5FloatType(8), true),
h5V2Msg(hdf5MsgDataLayout, 3, h5ContiguousLayout(dataAt, 16), true),
)
h5WriteOHDRv2(f, 96, 0x34, msgs)
binary.LittleEndian.PutUint64(f[dataAt:], math.Float64bits(1.5))
binary.LittleEndian.PutUint64(f[dataAt+8:], math.Float64bits(-2.5))
sets, err := LoadHDF5(writeHostile(t, "v2flags.h5", f))
if err != nil {
t.Fatalf("LoadHDF5 refused a flagged version 2 header: %v", err)
}
if len(sets) != 1 || sets[0].Path != "/" {
t.Fatalf("datasets = %v, want one dataset at /", sets)
}
if got := sets[0].Values.FloatAt(1); got != -2.5 {
t.Fatalf("value = %v, want -2.5", got)
}
}
// TestLoadHDF5V2ContinuationChecksum covers the happy path of a version
// 2 continuation: the dataset's messages live in an OCHK block whose
// lookup3 checksum covers the signature and the messages alike, and a
// correct checksum must be accepted (the hostile files above only ever
// see a broken one).
func TestLoadHDF5V2ContinuationChecksum(t *testing.T) {
const blockAt, dataAt = 224, 320
f := h5V3Superblock(512, 96)
msgs := slices.Concat(
h5V2Msg(hdf5MsgDataspace, 0, h5V2Dataspace(2), false),
h5V2Msg(hdf5MsgDatatype, 0, h5FloatType(8), false),
h5V2Msg(hdf5MsgDataLayout, 0, h5ContiguousLayout(dataAt, 16), false),
)
block := append([]byte{}, hdf5Chunk2...)
block = append(block, msgs...)
block = binary.LittleEndian.AppendUint32(block, hdf5Lookup3(block))
copy(f[blockAt:], block)
link := binary.LittleEndian.AppendUint64(
binary.LittleEndian.AppendUint64([]byte{}, blockAt), uint64(len(block)))
chunk := make([]byte, 4+len(link))
chunk[0] = hdf5MsgContinuation
binary.LittleEndian.PutUint16(chunk[1:], uint16(len(link)))
copy(chunk[4:], link)
h5WriteOHDRv2(f, 96, 0, chunk)
binary.LittleEndian.PutUint64(f[dataAt:], math.Float64bits(1.5))
binary.LittleEndian.PutUint64(f[dataAt+8:], math.Float64bits(-2.5))
sets, err := LoadHDF5(writeHostile(t, "v2cont-ok.h5", f))
if err != nil {
t.Fatalf("LoadHDF5 refused a checksummed version 2 continuation: %v", err)
}
if len(sets) != 1 || sets[0].Path != "/" {
t.Fatalf("datasets = %v, want one dataset at /", sets)
}
if got := sets[0].Values.FloatAt(0); got != 1.5 {
t.Fatalf("value = %v, want 1.5", got)
}
}
// TestLoadFITSTableNegativeTFIELDS pins the column count: a negative
// TFIELDS used to size the per-column slices with a negative length and
// panicked, in the binary and the ASCII branch alike. It is refused by
// name in both.
func TestLoadFITSTableNegativeTFIELDS(t *testing.T) {
for _, kind := range []string{"BINTABLE", "TABLE"} {
t.Run(kind, func(t *testing.T) {
hdr := cardBlock(
card("XTENSION= '"+kind+"'"),
card("BITPIX = 8"),
card("NAXIS = 2"),
card("NAXIS1 = 8"),
card("NAXIS2 = 1"),
card("PCOUNT = 0"),
card("GCOUNT = 1"),
card("TFIELDS = -1"),
card("END"),
)
path := writeHostile(t, "tfields.fits", append(hdr, make([]byte, 2880)...))
_, err := LoadFITSTable(path)
if err == nil {
t.Fatal("LoadFITSTable accepted TFIELDS = -1")
}
if !strings.Contains(err.Error(), "negative") {
t.Fatalf("error = %v, want the negative-count refusal", err)
}
})
}
}
// TestLoadFITSNaxisRefKeyword pins the NAXIS prefix: any keyword that
// started with NAXIS used to count as an axis, so a user keyword
// NAXISREF = 7 answered "NAXIS = 1 with 2 NAXISn cards" and refused a
// legal file. Only a number after the prefix is an axis, the rule the
// writer applies; NAXIS1 keeps counting.
func TestLoadFITSNaxisRefKeyword(t *testing.T) {
hdr := cardBlock(
card("SIMPLE = T"),
card("BITPIX = -64"),
card("NAXIS = 1"),
card("NAXIS1 = 2"),
card("NAXISREF= 7"),
card("END"),
)
payload := binary.BigEndian.AppendUint64(nil, math.Float64bits(1.5))
payload = binary.BigEndian.AppendUint64(payload, math.Float64bits(-0.5))
a, headers, err := LoadFITS(writeHostile(t, "naxisref.fits", append(hdr, payload...)))
if err != nil {
t.Fatalf("LoadFITS refused a file with a NAXISREF keyword: %v", err)
}
if a.Len() != 2 || a.FloatAt(0) != 1.5 || a.FloatAt(1) != -0.5 {
t.Fatalf("values = %v, want 1.5 and -0.5", a)
}
if got := headers["NAXISREF"]; got != "7" {
t.Fatalf("NAXISREF = %q, want it reported as a user keyword", got)
}
}