574 lines
21 KiB
Go
574 lines
21 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
package io
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/binary"
|
||
|
|
"fmt"
|
||
|
|
"math"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"runtime"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Regression tests for hostile inputs across the io package: the size
|
||
|
|
// arithmetic of the HDF5 reader, the NetCDF record-size
|
||
|
|
// pre-pass, the FITS table encoders and decoders, the mmap count
|
||
|
|
// conversion, and the CSV shape contract. Every hostile file below is
|
||
|
|
// built by hand, byte by byte, because the point of each test is a
|
||
|
|
// declared size the writer of a well-formed file would never produce.
|
||
|
|
|
||
|
|
// h5HostileFile returns an n-byte HDF5 file with the signature and a
|
||
|
|
// version 0 superblock (the classic layout: eight-byte addresses and
|
||
|
|
// lengths) whose root object header sits at offset 96.
|
||
|
|
func h5HostileFile(n int) []byte {
|
||
|
|
f := make([]byte, n)
|
||
|
|
copy(f, hdf5Magic)
|
||
|
|
f[8] = 0 // superblock version 0
|
||
|
|
f[13] = 8
|
||
|
|
f[14] = 8
|
||
|
|
binary.LittleEndian.PutUint64(f[32:], math.MaxUint64) // free space undefined
|
||
|
|
binary.LittleEndian.PutUint64(f[40:], uint64(n)) // end of file
|
||
|
|
binary.LittleEndian.PutUint64(f[48:], math.MaxUint64) // driver undefined
|
||
|
|
binary.LittleEndian.PutUint64(f[64:], 96) // root object header
|
||
|
|
return f
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5Msg is one object header message: its type and its body bytes.
|
||
|
|
type h5Msg struct {
|
||
|
|
typ uint16
|
||
|
|
body []byte
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5ObjectHeader writes a version 1 object header at off carrying msgs
|
||
|
|
// and returns the offset just past its message region.
|
||
|
|
func h5ObjectHeader(f []byte, off int, msgs ...h5Msg) int {
|
||
|
|
f[off] = 1
|
||
|
|
binary.LittleEndian.PutUint16(f[off+2:], uint16(len(msgs)))
|
||
|
|
binary.LittleEndian.PutUint32(f[off+4:], 1) // reference count
|
||
|
|
region := off + 16
|
||
|
|
size := 0
|
||
|
|
for _, m := range msgs {
|
||
|
|
size = alignUp(size+8+len(m.body), 8)
|
||
|
|
}
|
||
|
|
binary.LittleEndian.PutUint32(f[off+8:], uint32(size))
|
||
|
|
for _, m := range msgs {
|
||
|
|
binary.LittleEndian.PutUint16(f[region:], m.typ)
|
||
|
|
binary.LittleEndian.PutUint16(f[region+2:], uint16(len(m.body)))
|
||
|
|
copy(f[region+8:], m.body)
|
||
|
|
region += alignUp(8+len(m.body), 8)
|
||
|
|
}
|
||
|
|
return region
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5Dataspace renders a version 1 dataspace message with the given
|
||
|
|
// extents, each written as an eight-byte length.
|
||
|
|
func h5Dataspace(dims ...uint64) []byte {
|
||
|
|
m := make([]byte, 8+8*len(dims))
|
||
|
|
m[0] = 1 // version 1
|
||
|
|
if len(dims) == 0 {
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
m[1] = byte(len(dims))
|
||
|
|
for i, d := range dims {
|
||
|
|
binary.LittleEndian.PutUint64(m[8+8*i:], d)
|
||
|
|
}
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5FloatType renders a version 1 floating-point datatype message of the
|
||
|
|
// given element width.
|
||
|
|
func h5FloatType(size uint32) []byte {
|
||
|
|
m := make([]byte, 20)
|
||
|
|
m[0] = 0x11 // version 1, class 1 (floating-point)
|
||
|
|
binary.LittleEndian.PutUint32(m[4:], size)
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5ChunkLayoutV4 renders a version 4 chunked layout message whose chunk
|
||
|
|
// dimensions are eight bytes wide, the flag the version 4 message
|
||
|
|
// carries, so an extent beyond 2^32 can be declared at all.
|
||
|
|
func h5ChunkLayoutV4(btree uint64, dims ...uint64) []byte {
|
||
|
|
m := make([]byte, 3+8+1+8*len(dims))
|
||
|
|
m[0] = 4
|
||
|
|
m[1] = 2 // chunked
|
||
|
|
m[2] = byte(len(dims))
|
||
|
|
binary.LittleEndian.PutUint64(m[3:], btree)
|
||
|
|
m[11] = 1 // eight-byte chunk dimensions
|
||
|
|
for i, d := range dims {
|
||
|
|
binary.LittleEndian.PutUint64(m[12+8*i:], d)
|
||
|
|
}
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5ChunkLayoutV3 renders a version 3 chunked layout message with
|
||
|
|
// four-byte chunk dimensions.
|
||
|
|
func h5ChunkLayoutV3(btree uint64, dims ...uint32) []byte {
|
||
|
|
m := make([]byte, 3+8+4*len(dims))
|
||
|
|
m[0] = 3
|
||
|
|
m[1] = 2
|
||
|
|
m[2] = byte(len(dims))
|
||
|
|
binary.LittleEndian.PutUint64(m[3:], btree)
|
||
|
|
for i, d := range dims {
|
||
|
|
binary.LittleEndian.PutUint32(m[11+4*i:], d)
|
||
|
|
}
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5ContiguousLayout renders a version 3 contiguous layout message.
|
||
|
|
func h5ContiguousLayout(addr, size uint64) []byte {
|
||
|
|
m := make([]byte, 2+8+8)
|
||
|
|
m[0] = 3
|
||
|
|
m[1] = 1 // contiguous
|
||
|
|
binary.LittleEndian.PutUint64(m[2:], addr)
|
||
|
|
binary.LittleEndian.PutUint64(m[10:], size)
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
// h5ChunkTree writes a one-entry leaf chunk B-tree at off for a dataset
|
||
|
|
// of the given rank: one chunk of size stored bytes at chunkAt, with no
|
||
|
|
// filter mask, at the given chunk offsets. The key carries one more
|
||
|
|
// offset than the dataset has axes, the element size, which is why the
|
||
|
|
// entry is 8+(8*(rank+1))+offSize bytes long.
|
||
|
|
func h5ChunkTree(f []byte, off, rank int, size uint32, offsets []uint64, chunkAt uint64) {
|
||
|
|
copy(f[off:], hdf5Tree)
|
||
|
|
f[off+4] = 1 // chunk tree
|
||
|
|
f[off+5] = 0 // leaf level
|
||
|
|
binary.LittleEndian.PutUint16(f[off+6:], 1)
|
||
|
|
p := off + 24
|
||
|
|
binary.LittleEndian.PutUint32(f[p:], size)
|
||
|
|
binary.LittleEndian.PutUint32(f[p+4:], 0) // filter mask
|
||
|
|
for i := range rank {
|
||
|
|
binary.LittleEndian.PutUint64(f[p+8+8*i:], offsets[i])
|
||
|
|
}
|
||
|
|
binary.LittleEndian.PutUint64(f[p+8+8*rank:], 8) // the element-size key
|
||
|
|
binary.LittleEndian.PutUint64(f[p+8+8*(rank+1):], chunkAt)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadHDF5ChunkExtentWrap pins the chunk-dimension bound. The chunk
|
||
|
|
// shape 2^32 by 2^29 float64 elements is 2^64 bytes, which wraps to
|
||
|
|
// zero: the "chunk above the reader budget" guard and the "chunk holds a
|
||
|
|
// full chunk" guard both saw 0, so the chunk strides were walked out of
|
||
|
|
// the stored chunk (a slice out of range on a 2^61-element declaration,
|
||
|
|
// and a silent read of the file's other bytes on a milder one).
|
||
|
|
func TestLoadHDF5ChunkExtentWrap(t *testing.T) {
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
dims []uint64 // the chunk's shape plus the element-size slot
|
||
|
|
}{
|
||
|
|
{"2^32 by 2^29 elements", []uint64{1 << 32, 1 << 29, 8}},
|
||
|
|
{"2^61 elements", []uint64{1 << 61, 1, 8}},
|
||
|
|
}
|
||
|
|
for _, tc := range cases {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
const btree, chunkAt, n = 320, 448, 512
|
||
|
|
f := h5HostileFile(n)
|
||
|
|
end := h5ObjectHeader(f, 96,
|
||
|
|
h5Msg{hdf5MsgDataspace, h5Dataspace(2, 1)},
|
||
|
|
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
|
||
|
|
h5Msg{hdf5MsgDataLayout, h5ChunkLayoutV4(btree, tc.dims...)},
|
||
|
|
)
|
||
|
|
if end > btree {
|
||
|
|
t.Fatalf("the test object header runs to %d, past the chunk B-tree at %d", end, btree)
|
||
|
|
}
|
||
|
|
h5ChunkTree(f, btree, 2, 16, []uint64{0, 0}, chunkAt)
|
||
|
|
path := writeHostile(t, "chunkextent.h5", f)
|
||
|
|
sets, err := LoadHDF5(path)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatalf("LoadHDF5 accepted a chunk whose extent wraps: %d datasets", len(sets))
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "budget") {
|
||
|
|
t.Fatalf("error = %v, want the reader-budget refusal", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadHDF5DatasetExtentWrap pins the dataspace bound on both storage
|
||
|
|
// classes: a shape of 2^32 by 2^32 float64 elements wraps the byte count
|
||
|
|
// to zero, which passed the cap, so the output buffer was allocated
|
||
|
|
// empty while the copy needed a whole element (the chunked class
|
||
|
|
// panicked), and the contiguous class returned a dataset claiming 2^64
|
||
|
|
// elements with an empty payload and no error.
|
||
|
|
func TestLoadHDF5DatasetExtentWrap(t *testing.T) {
|
||
|
|
t.Run("chunked", func(t *testing.T) {
|
||
|
|
const btree, chunkAt, n = 320, 448, 512
|
||
|
|
f := h5HostileFile(n)
|
||
|
|
end := h5ObjectHeader(f, 96,
|
||
|
|
h5Msg{hdf5MsgDataspace, h5Dataspace(1<<32, 1<<32)},
|
||
|
|
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
|
||
|
|
h5Msg{hdf5MsgDataLayout, h5ChunkLayoutV3(btree, 1, 1, 8)},
|
||
|
|
)
|
||
|
|
if end > btree {
|
||
|
|
t.Fatalf("the test object header runs to %d, past the chunk B-tree at %d", end, btree)
|
||
|
|
}
|
||
|
|
h5ChunkTree(f, btree, 2, 8, []uint64{0, 0}, chunkAt)
|
||
|
|
sets, err := LoadHDF5(writeHostile(t, "shapedwrap.h5", f))
|
||
|
|
if err == nil {
|
||
|
|
t.Fatalf("LoadHDF5 accepted a wrapping dataspace: %d datasets", len(sets))
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "budget") {
|
||
|
|
t.Fatalf("error = %v, want the reader-budget refusal", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
t.Run("contiguous", func(t *testing.T) {
|
||
|
|
const dataAt, n = 448, 512
|
||
|
|
f := h5HostileFile(n)
|
||
|
|
h5ObjectHeader(f, 96,
|
||
|
|
h5Msg{hdf5MsgDataspace, h5Dataspace(1<<32, 1<<32)},
|
||
|
|
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
|
||
|
|
h5Msg{hdf5MsgDataLayout, h5ContiguousLayout(dataAt, 8)},
|
||
|
|
)
|
||
|
|
sets, err := LoadHDF5(writeHostile(t, "contigwrap.h5", f))
|
||
|
|
if err == nil {
|
||
|
|
shape := []int(nil)
|
||
|
|
if len(sets) > 0 {
|
||
|
|
shape = sets[0].Shape
|
||
|
|
}
|
||
|
|
t.Fatalf("LoadHDF5 returned %v for a wrapping dataspace, want an error", shape)
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "budget") {
|
||
|
|
t.Fatalf("error = %v, want the reader-budget refusal", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadHDF5AttributeExtentWrap pins the attribute dataspace bound: a
|
||
|
|
// 3 by 2^62 element dataspace wraps its element count to a negative
|
||
|
|
// number in the multiply-first check, which let it past the bounds test
|
||
|
|
// and into an allocation with a negative capacity. The attribute must be
|
||
|
|
// refused (dropped), not accepted with a wrapped value.
|
||
|
|
func TestLoadHDF5AttributeExtentWrap(t *testing.T) {
|
||
|
|
const n = 512
|
||
|
|
f := h5HostileFile(n)
|
||
|
|
// The attribute message: version, flags, then the three size fields
|
||
|
|
// with their fields behind them. A 2^62 extent is far past the
|
||
|
|
// message, so no legal value can follow it.
|
||
|
|
attr := make([]byte, 88)
|
||
|
|
attr[0] = 1
|
||
|
|
binary.LittleEndian.PutUint16(attr[2:], 2) // name size
|
||
|
|
binary.LittleEndian.PutUint16(attr[4:], 20) // datatype size
|
||
|
|
binary.LittleEndian.PutUint16(attr[6:], 24) // dataspace size
|
||
|
|
copy(attr[8:], "n\x00")
|
||
|
|
copy(attr[16:], h5FloatType(8))
|
||
|
|
copy(attr[40:], h5Dataspace(3, 1<<62))
|
||
|
|
// A hard link to a one-element dataset, so the attribute's fate is
|
||
|
|
// observable: an accepted attribute lands in the dataset's map.
|
||
|
|
link := func(addr uint64) []byte {
|
||
|
|
return binary.LittleEndian.AppendUint64([]byte{1, 0, 1, 'd'}, addr)
|
||
|
|
}
|
||
|
|
datasetAt := 288
|
||
|
|
end := h5ObjectHeader(f, 96,
|
||
|
|
h5Msg{hdf5MsgLink, link(uint64(datasetAt))},
|
||
|
|
h5Msg{hdf5MsgAttribute, attr},
|
||
|
|
)
|
||
|
|
if end > datasetAt {
|
||
|
|
t.Fatalf("the test object header runs to %d, past the dataset at %d", end, datasetAt)
|
||
|
|
}
|
||
|
|
h5ObjectHeader(f, datasetAt,
|
||
|
|
h5Msg{hdf5MsgDataspace, h5Dataspace(1)},
|
||
|
|
h5Msg{hdf5MsgDatatype, h5FloatType(8)},
|
||
|
|
h5Msg{hdf5MsgDataLayout, h5ContiguousLayout(448, 8)},
|
||
|
|
)
|
||
|
|
sets, err := LoadHDF5(writeHostile(t, "attrextent.h5", f))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadHDF5 refused the file: %v", err)
|
||
|
|
}
|
||
|
|
if len(sets) != 1 || sets[0].Path != "/d" {
|
||
|
|
t.Fatalf("datasets = %d, want the linked /d", len(sets))
|
||
|
|
}
|
||
|
|
if v, ok := sets[0].Attrs["n"]; ok {
|
||
|
|
t.Fatalf("the attribute with a wrapping dataspace was accepted as %q", v)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadHDF5MessageCountBounded pins the object header preallocation:
|
||
|
|
// the slice used to be sized from the header's declared message count
|
||
|
|
// (65535 messages of 32 bytes is 2 MiB) before the region that would
|
||
|
|
// have to hold them was consulted, so 16 bytes of file ordered a
|
||
|
|
// two-megabyte allocation.
|
||
|
|
func TestLoadHDF5MessageCountBounded(t *testing.T) {
|
||
|
|
f := h5HostileFile(160)
|
||
|
|
f[96] = 1
|
||
|
|
binary.LittleEndian.PutUint16(f[98:], 65535) // declared messages
|
||
|
|
binary.LittleEndian.PutUint32(f[100:], 1) // reference count
|
||
|
|
binary.LittleEndian.PutUint32(f[104:], 16) // the region that holds them
|
||
|
|
path := writeHostile(t, "msgcount.h5", f)
|
||
|
|
runtime.GC()
|
||
|
|
var before, after runtime.MemStats
|
||
|
|
runtime.ReadMemStats(&before)
|
||
|
|
_, err := LoadHDF5(path)
|
||
|
|
runtime.ReadMemStats(&after)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("LoadHDF5 accepted a header whose messages run past the region")
|
||
|
|
}
|
||
|
|
if used := after.TotalAlloc - before.TotalAlloc; used > 512<<10 {
|
||
|
|
t.Fatalf("loading a 160-byte file allocated %d bytes: the declared message count still sizes the preallocation", used)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadNetCDFRecordSizeWrap pins the record-size pre-pass. It used to
|
||
|
|
// multiply every record variable's non-record dimensions before any
|
||
|
|
// variable was validated, so two hostile declarations inflated the
|
||
|
|
// record size for an innocuous variable to 2^54 bytes: one element per
|
||
|
|
// record then made (numrecs-1)*recordSize exactly 2^64, which wrapped to
|
||
|
|
// zero, the span check passed and the reader walked 2^54 bytes into a
|
||
|
|
// 4 KiB file.
|
||
|
|
func TestLoadNetCDFRecordSizeWrap(t *testing.T) {
|
||
|
|
const numrecs = 1<<10 + 1
|
||
|
|
// The arithmetic the reader performed: two slabs of 2^25 by 2^25
|
||
|
|
// doubles are 2^53 bytes each and the innocuous variable's slab is
|
||
|
|
// four, so the record size is 2^54+4 and the last record's slab
|
||
|
|
// offset wraps to 2^64+4096, i.e. 4096. A file larger than that
|
||
|
|
// passes the span check and the following records then index far
|
||
|
|
// past the file.
|
||
|
|
recordSize := 2*int64(1<<53) + 4
|
||
|
|
if span := (numrecs-1)*recordSize + 1; span != 1<<12+1 {
|
||
|
|
t.Fatalf("construction is wrong: the wrapped span is %d, want %d", span, 1<<12+1)
|
||
|
|
}
|
||
|
|
|
||
|
|
var b []byte
|
||
|
|
b = append(b, 'C', 'D', 'F', 1)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, numrecs)
|
||
|
|
// dim_list: the record dimension leads, then one one-element
|
||
|
|
// dimension and the two hostile ones.
|
||
|
|
b = binary.BigEndian.AppendUint32(b, ncTagDimension)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 4)
|
||
|
|
b = hostileNCName(b, "rec")
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0)
|
||
|
|
b = hostileNCName(b, "one")
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 1)
|
||
|
|
b = hostileNCName(b, "big1")
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 1<<25)
|
||
|
|
b = hostileNCName(b, "big2")
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 1<<25)
|
||
|
|
// gatt_list: absent.
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0)
|
||
|
|
// var_list: the innocuous record variable, then the two that inflate
|
||
|
|
// the record size.
|
||
|
|
b = binary.BigEndian.AppendUint32(b, ncTagVariable)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 3)
|
||
|
|
b = hostileNCName(b, "a")
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 2) // rank: rec, one
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 1)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0) // variable attributes: absent
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, ncTypeByte)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 4) // vsize
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0) // begin
|
||
|
|
for i := range 2 {
|
||
|
|
b = hostileNCName(b, fmt.Sprintf("b%d", i))
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 3) // rank: rec, big1, big2
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 2)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 3)
|
||
|
|
b = binary.BigEndian.AppendUint32(b, 0) // variable attributes: absent
|
||
|
|
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
|
||
|
|
}
|
||
|
|
// A file long enough for the innocuous variable's own records and for
|
||
|
|
// the wrapped span the buggy check computes.
|
||
|
|
for len(b) < 8192 {
|
||
|
|
b = append(b, 0)
|
||
|
|
}
|
||
|
|
path := writeHostile(t, "recsize-wrap.nc", b)
|
||
|
|
_, _, _, err := LoadNetCDF(path)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("LoadNetCDF accepted a record variable whose dimensions cannot fit the file")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "more elements than the file holds") {
|
||
|
|
t.Fatalf("error = %v, want the per-variable element bound", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSaveFITSTableASCIIFloat32 pins the ASCII float encoder for a
|
||
|
|
// float32 column. The encoder read the float64 payload first and only
|
||
|
|
// then substituted the float32 one, but RawFloats is nil for every
|
||
|
|
// float32 array, so the first row of an ordinary float32 column indexed
|
||
|
|
// a nil slice.
|
||
|
|
func TestSaveFITSTableASCIIFloat32(t *testing.T) {
|
||
|
|
vals := []float32{1.5, -2.25, 3e8}
|
||
|
|
f32 := core.New(core.Float32, len(vals))
|
||
|
|
copy(f32.RawFloat32s(), vals)
|
||
|
|
cols := []FITSTableColumn{{Name: "F", Form: "E12.4", Data: f32}}
|
||
|
|
path := filepath.Join(t.TempDir(), "f32ascii.fits")
|
||
|
|
if err := SaveFITSTable(path, true, cols, nil); err != nil {
|
||
|
|
t.Fatalf("SaveFITSTable: %v", err)
|
||
|
|
}
|
||
|
|
table, err := LoadFITSTable(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadFITSTable: %v", err)
|
||
|
|
}
|
||
|
|
for i, want := range vals {
|
||
|
|
if got := table.Columns[0].FloatAt(i); got != float64(want) {
|
||
|
|
t.Fatalf("F[%d] = %g, want %g", i, got, float64(want))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadFITSTableFortranNumbers pins the number forms a Fortran writer
|
||
|
|
// puts in an ASCII table: strconv.ParseFloat accepts neither the Dw.d
|
||
|
|
// exponent ("1.5D+03") nor the exponent-less form ("1.5+03"), and one
|
||
|
|
// unparseable cell refused the whole table.
|
||
|
|
func TestLoadFITSTableFortranNumbers(t *testing.T) {
|
||
|
|
hdr := cardBlock(
|
||
|
|
card("XTENSION= 'TABLE '"),
|
||
|
|
card("BITPIX = 8"),
|
||
|
|
card("NAXIS = 2"),
|
||
|
|
card("NAXIS1 = 20"),
|
||
|
|
card("NAXIS2 = 4"),
|
||
|
|
card("TFIELDS = 1"),
|
||
|
|
card("TBCOL1 = 1"),
|
||
|
|
card("TFORM1 = 'D20.12'"),
|
||
|
|
card("END"),
|
||
|
|
)
|
||
|
|
rows := []string{"1.5D+03", "1.5+03", "-2.5d-2", "1.5E+03"}
|
||
|
|
payload := make([]byte, 0, 20*len(rows))
|
||
|
|
for _, r := range rows {
|
||
|
|
payload = append(payload, fmt.Sprintf("%-20s", r)...)
|
||
|
|
}
|
||
|
|
path := writeHostile(t, "fortran.fits", append(hdr, payload...))
|
||
|
|
table, err := LoadFITSTable(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadFITSTable refused a Fortran table: %v", err)
|
||
|
|
}
|
||
|
|
want := []float64{1500, 1500, -0.025, 1500}
|
||
|
|
for i, w := range want {
|
||
|
|
if got := table.Columns[0].FloatAt(i); got != w {
|
||
|
|
t.Fatalf("row %d (%q) = %g, want %g", i, rows[i], got, w)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoadFITSTableScaleOverflow pins the integral-scaling bound. The
|
||
|
|
// guard used 9.3e18, which is above MaxInt64 (9.223372036854775807e18),
|
||
|
|
// so a scaled value in [2^63, 9.3e18) reached an out-of-range
|
||
|
|
// float-to-integer conversion: the column kept the int dtype and the
|
||
|
|
// value came back as MinInt64 instead of the documented promotion to
|
||
|
|
// float64.
|
||
|
|
func TestLoadFITSTableScaleOverflow(t *testing.T) {
|
||
|
|
const raw = int64(1) << 62
|
||
|
|
cols := []FITSTableColumn{{Name: "V", Form: "K", Data: mustInts(t, []int64{raw}, 1)}}
|
||
|
|
path := filepath.Join(t.TempDir(), "scaled.fits")
|
||
|
|
if err := SaveFITSTable(path, false, cols, map[string]string{"TSCAL1": "2", "TZERO1": "0"}); err != nil {
|
||
|
|
t.Fatalf("SaveFITSTable: %v", err)
|
||
|
|
}
|
||
|
|
table, err := LoadFITSTable(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadFITSTable: %v", err)
|
||
|
|
}
|
||
|
|
col := table.Columns[0]
|
||
|
|
want := float64(raw) * 2 // 2^63, exactly representable in float64
|
||
|
|
if col.Dtype() != core.Float {
|
||
|
|
t.Fatalf("dtype = %s, want float64: a scaling past MaxInt64 must promote the column", col.Dtype())
|
||
|
|
}
|
||
|
|
if got := col.FloatAt(0); got != want {
|
||
|
|
t.Fatalf("scaled value = %g, want %g", got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestMapCountsWithoutWrap pins the mmap count arithmetic: the byte
|
||
|
|
// length used to be int64(n)*elementSize, which wraps for a hostile
|
||
|
|
// count, so the wrapped length passed the file-size checks and the typed
|
||
|
|
// view was then built with the original count, which unsafe.Slice
|
||
|
|
// rejects with a panic instead of an error.
|
||
|
|
func TestMapCountsWithoutWrap(t *testing.T) {
|
||
|
|
path := filepath.Join(t.TempDir(), "small.bin")
|
||
|
|
if err := SaveNativeFloats(path, []float64{1, 2, 3, 4}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
// 2^62+3 float64 values are 2^65+24 bytes, which wraps to 24: the
|
||
|
|
// file holds 32. The others already failed the non-positive-length
|
||
|
|
// check by luck and must keep failing it.
|
||
|
|
for _, n := range []int{1<<62 + 3, 1 << 61, 1 << 60, math.MaxInt} {
|
||
|
|
if _, _, err := MapFloats(path, 0, n); err == nil {
|
||
|
|
t.Errorf("MapFloats(n = %d) accepted a count the file cannot hold", n)
|
||
|
|
}
|
||
|
|
if _, _, err := MapFloat32s(path, 0, n); err == nil {
|
||
|
|
t.Errorf("MapFloat32s(n = %d) accepted a count the file cannot hold", n)
|
||
|
|
}
|
||
|
|
if _, _, err := MapInts(path, 0, n); err == nil {
|
||
|
|
t.Errorf("MapInts(n = %d) accepted a count the file cannot hold", n)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSaveCSVZeroColumns pins the shape contract: an array with rows and
|
||
|
|
// no columns wrote one empty record per row, and encoding/csv reads
|
||
|
|
// blank lines as no records at all, so (3,0) came back as (0,0). It is
|
||
|
|
// refused instead, because no CSV text can carry the column count.
|
||
|
|
func TestSaveCSVZeroColumns(t *testing.T) {
|
||
|
|
a, err := core.FromFloats(nil, 3, 0)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FromFloats(nil, 3, 0): %v", err)
|
||
|
|
}
|
||
|
|
var sb strings.Builder
|
||
|
|
if err := SaveCSVWriter(&sb, a); err == nil {
|
||
|
|
t.Fatalf("SaveCSVWriter accepted the shape %v and wrote %q, which reads back as a different shape",
|
||
|
|
a.Shape(), sb.String())
|
||
|
|
}
|
||
|
|
// A 2-D array with columns still round-trips.
|
||
|
|
ok := mustFloats(t, []float64{1, 2, 3, 4, 5, 6}, 2, 3)
|
||
|
|
var back strings.Builder
|
||
|
|
if err := SaveCSVWriter(&back, ok); err != nil {
|
||
|
|
t.Fatalf("SaveCSVWriter: %v", err)
|
||
|
|
}
|
||
|
|
got, err := LoadCSVReader(strings.NewReader(back.String()), false)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadCSVReader: %v", err)
|
||
|
|
}
|
||
|
|
if got.Shape()[0] != 2 || got.Shape()[1] != 3 {
|
||
|
|
t.Fatalf("round trip gave %v, want [2 3]", got.Shape())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestIOSourceTypography pins the plain-ASCII rule for the package's own
|
||
|
|
// source: an em dash, an en dash, a Unicode minus, a middle dot or a
|
||
|
|
// multiplication sign in a comment or a message is a typographic symbol
|
||
|
|
// where the repository's style asks for ASCII.
|
||
|
|
func TestIOSourceTypography(t *testing.T) {
|
||
|
|
banned := []struct {
|
||
|
|
r rune
|
||
|
|
name string
|
||
|
|
}{
|
||
|
|
{'\u2014', "em dash"},
|
||
|
|
{'\u2013', "en dash"},
|
||
|
|
{'\u2212', "Unicode minus"},
|
||
|
|
{'\u00b7', "middle dot"},
|
||
|
|
{'\u00d7', "multiplication sign"},
|
||
|
|
}
|
||
|
|
entries, err := os.ReadDir(".")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ReadDir: %v", err)
|
||
|
|
}
|
||
|
|
checked := 0
|
||
|
|
for _, e := range entries {
|
||
|
|
name := e.Name()
|
||
|
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
data, err := os.ReadFile(name)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ReadFile(%s): %v", name, err)
|
||
|
|
}
|
||
|
|
checked++
|
||
|
|
for _, b := range banned {
|
||
|
|
if strings.ContainsRune(string(data), b.r) {
|
||
|
|
t.Errorf("%s carries a %s (U+%04X)", name, b.name, b.r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if checked == 0 {
|
||
|
|
t.Fatal("no source files were checked")
|
||
|
|
}
|
||
|
|
}
|