Files
clawhdf5/crates/clawhdf5-format/src/fixed_array.rs
T
osobhandClaude Opus 5 b41272487a fix(format): verify Fixed and Extensible Array checksums
Every structure in both chunk indexes — header, index block, super
block, data block and each data block page — carries a Jenkins lookup3
checksum, and all of them were parsed past and ignored.

What that costs is not a warning but correct data. Flip one low bit of a
chunk address and the index still has the right shape, the address still
lands inside the file, and the reader returns whatever bytes now sit
there as that chunk's contents. Nothing else in the parse can tell.

Verified in both directions. The checksums accept files written by
HDF5 2.0 from 100 to 200 000 chunks — dense, sparse, gzip-filtered and
paged — which also confirms the block layouts byte for byte, since a
wrong offset would fail every file. And an interop test corrupts an
address to check the read fails instead of returning data: removing the
verification makes that test fail with "corruption produced data instead
of an error", which is what it is there to prove.

The first version of that test passed with verification disabled — it
corrupted a byte a structural check already rejected, so it proved
nothing. Worth recording, since a test that passes for the wrong reason
looks exactly like coverage.

Hand-built fixtures now stamp real checksums, as HDF5 writers do, and
the Extensible Array ones no longer describe the superseded layout.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:41:41 -07:00

862 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! HDF5 Fixed Array index parsing for chunked datasets (v4 index type 3).
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec};
use crate::chunked_read::ChunkInfo;
use crate::error::FormatError;
/// Verify the Jenkins lookup3 checksum stored immediately after
/// `data[start..end]`, as every Fixed Array structure carries one.
///
/// A corrupt chunk index silently yields addresses pointing at the wrong
/// bytes, so a mismatch has to be an error rather than a shrug: without this
/// the damage surfaces as plausible-looking data from the wrong chunk.
#[cfg(feature = "checksum")]
fn verify_checksum(data: &[u8], start: usize, end: usize) -> Result<(), FormatError> {
ensure_len(data, end, 4)?;
let stored = u32::from_le_bytes([data[end], data[end + 1], data[end + 2], data[end + 3]]);
let computed = crate::checksum::jenkins_lookup3(&data[start..end]);
if computed != stored {
return Err(FormatError::ChecksumMismatch {
expected: stored,
computed,
});
}
Ok(())
}
#[cfg(not(feature = "checksum"))]
fn verify_checksum(_data: &[u8], _start: usize, _end: usize) -> Result<(), FormatError> {
Ok(())
}
/// Parsed Fixed Array header (FAHD).
#[derive(Debug, Clone)]
pub struct FixedArrayHeader {
/// Client ID: 0 = non-filtered chunks, 1 = filtered chunks.
pub client_id: u8,
/// Size of each array element in bytes.
pub element_size: u8,
/// Log2 of max number of elements in a data block page.
pub max_nelmts_bits: u8,
/// Total number of elements (chunks) in the array.
pub num_elements: u64,
/// Address of the data block.
pub data_block_address: u64,
}
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
let s = size as usize;
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
return Err(FormatError::UnexpectedEof {
expected: pos.saturating_add(s),
available: data.len(),
});
}
let slice = &data[pos..pos + s];
Ok(match size {
2 => u16::from_le_bytes([slice[0], slice[1]]) as u64,
4 => u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]) as u64,
8 => u64::from_le_bytes([
slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
]),
_ => return Err(FormatError::InvalidOffsetSize(size)),
})
}
fn read_length(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
read_offset(data, pos, size)
}
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
fn is_undefined(data: &[u8], pos: usize, size: u8) -> bool {
let s = size as usize;
if pos + s > data.len() {
return false;
}
data[pos..pos + s].iter().all(|&b| b == 0xFF)
}
impl FixedArrayHeader {
/// Parse a Fixed Array header from file data at the given offset.
pub fn parse(
file_data: &[u8],
offset: usize,
offset_size: u8,
length_size: u8,
) -> Result<Self, FormatError> {
// FAHD signature(4) + version(1) + client_id(1) + element_size(1) +
// max_nelmts_bits(1) + num_elements(length_size) + data_block_addr(offset_size) + checksum(4)
let min_size = 4 + 1 + 1 + 1 + 1 + length_size as usize + offset_size as usize + 4;
ensure_len(file_data, offset, min_size)?;
let d = &file_data[offset..];
if &d[0..4] != b"FAHD" {
return Err(FormatError::ChunkedReadError(
"invalid Fixed Array header signature".into(),
));
}
let version = d[4];
if version != 0 {
return Err(FormatError::ChunkedReadError(format!(
"unsupported Fixed Array header version: {version}"
)));
}
let client_id = d[5];
let element_size = d[6];
let max_nelmts_bits = d[7];
let mut pos = 8;
let num_elements = read_length(d, pos, length_size)?;
pos += length_size as usize;
let data_block_address = read_offset(d, pos, offset_size)?;
pos += offset_size as usize;
verify_checksum(file_data, offset, offset + pos)?;
Ok(FixedArrayHeader {
client_id,
element_size,
max_nelmts_bits,
num_elements,
data_block_address,
})
}
}
/// Read chunk records from a Fixed Array data block.
///
/// Returns a `Vec<ChunkInfo>` with one entry per allocated chunk.
/// `chunk_dimensions` should be the spatial chunk dims only (not including the element-size dim).
/// `element_size` is the datatype size in bytes.
#[allow(clippy::too_many_arguments)]
pub fn read_fixed_array_chunks(
file_data: &[u8],
header: &FixedArrayHeader,
dataset_dims: &[u64],
chunk_dimensions: &[u32],
element_size: u32,
offset_size: u8,
_length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> {
let db_offset = header.data_block_address as usize;
let rank = chunk_dimensions.len();
// Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize;
ensure_len(file_data, db_offset, db_header_size)?;
let d = &file_data[db_offset..];
if &d[0..4] != b"FADB" {
return Err(FormatError::ChunkedReadError(
"invalid Fixed Array data block signature".into(),
));
}
// Elements start immediately after the data block prefix.
let elements_start = db_offset + db_header_size;
let num_elements = header.num_elements as usize;
// A chunk index cannot describe more elements than the file has bytes (each
// element occupies at least `offset_size` bytes). Reject a corrupt count
// before it can drive a huge loop or overflow an offset computation.
if num_elements > file_data.len() {
return Err(FormatError::ChunkedReadError(
"Fixed Array element count exceeds file size".into(),
));
}
let os = offset_size as usize;
// On-disk stride of one element. For non-filtered arrays the element is just
// the chunk address (== offset_size); for filtered arrays it is
// address + chunk_size + filter_mask (== header.element_size).
let elem_stride = (header.element_size as usize).max(os);
// Absolute file offset of element `idx` within a run starting at `base`,
// with overflow surfaced as a clean error rather than a panic/wrap.
let elem_at = |base: usize, idx: usize| -> Result<usize, FormatError> {
idx.checked_mul(elem_stride)
.and_then(|o| base.checked_add(o))
.ok_or(FormatError::ChunkedReadError(
"Fixed Array element offset overflow".into(),
))
};
// Compute chunk offsets based on index.
// Chunks are stored in row-major order within the dataset space.
let mut num_chunks_per_dim = Vec::with_capacity(rank);
for d_idx in 0..rank {
let ch_dim = chunk_dimensions[d_idx] as u64;
if ch_dim == 0 {
return Err(FormatError::ChunkedReadError(
"chunk dimension is zero".into(),
));
}
let ds_dim = dataset_dims[d_idx];
num_chunks_per_dim.push(ds_dim.div_ceil(ch_dim));
}
let chunk_byte_size: u64 =
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
let mut chunks = Vec::new();
let push_element =
|i: usize, abs: usize, chunks: &mut Vec<ChunkInfo>| -> Result<(), FormatError> {
if let Some((address, chunk_size, filter_mask)) = parse_fa_element(
file_data,
abs,
header.client_id,
offset_size,
header.element_size,
chunk_byte_size,
)? {
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
chunks.push(ChunkInfo {
chunk_size,
filter_mask,
offsets,
address,
});
}
Ok(())
};
// A data block is paged when it holds more elements than fit in one page.
// `max_nelmts_bits` is an untrusted u8; a shift >= the pointer width would
// panic, so reject it (real page-size bits are tiny — 10 by default).
if header.max_nelmts_bits as u32 >= usize::BITS {
return Err(FormatError::ChunkedReadError(
"Fixed Array max_nelmts_bits too large".into(),
));
}
let page_nelmts = 1usize << header.max_nelmts_bits;
let is_paged = num_elements > page_nelmts;
if !is_paged {
// Non-paged: prefix, then `num_elements` elements packed directly,
// then a checksum over both.
verify_checksum(file_data, db_offset, elem_at(elements_start, num_elements)?)?;
for i in 0..num_elements {
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
}
return Ok(chunks);
}
// Paged layout: prefix, then a page-init bitmap (one bit per page, MSB-first
// within each byte), then a 4-byte checksum, then the pages. Every page
// occupies a full slot of `page_nelmts` elements plus a 4-byte checksum;
// only the final page holds fewer elements. Uninitialized pages (bit clear)
// still occupy their slot on disk but are zero-filled, so the bitmap — not a
// 0xFF sentinel — is what marks a whole page as unallocated.
let stride_overflow =
|| FormatError::ChunkedReadError("Fixed Array page offset overflow".into());
let npages = num_elements.div_ceil(page_nelmts);
let bitmap_size = npages.div_ceil(8);
let bitmap_start = elements_start;
// prefix(db_header_size) + bitmap + checksum(4)
let pages_start = db_offset + db_header_size + bitmap_size + 4;
let page_stride = page_nelmts
.checked_mul(elem_stride)
.and_then(|x| x.checked_add(4))
.ok_or_else(stride_overflow)?;
if bitmap_start + bitmap_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: bitmap_start + bitmap_size,
available: file_data.len(),
});
}
// The prefix and page bitmap are covered by their own checksum, and each
// initialised page by one of its own.
verify_checksum(file_data, db_offset, bitmap_start + bitmap_size)?;
for p in 0..npages {
let page_first = p * page_nelmts; // < num_elements, cannot overflow
let page_count = core::cmp::min(page_nelmts, num_elements - page_first);
// Check the page-init bit (MSB-first within each byte).
let bit_byte = file_data[bitmap_start + p / 8];
let bit_mask = 1u8 << (7 - (p % 8));
if bit_byte & bit_mask == 0 {
continue; // entire page unallocated
}
let page_off = p
.checked_mul(page_stride)
.and_then(|o| pages_start.checked_add(o))
.ok_or_else(stride_overflow)?;
verify_checksum(file_data, page_off, elem_at(page_off, page_count)?)?;
for e in 0..page_count {
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
}
}
Ok(chunks)
}
/// Parse a single Fixed Array element at absolute file offset `abs`.
///
/// Returns `Some((address, chunk_size, filter_mask))` for an allocated chunk, or
/// `None` if the element is undefined (an unallocated chunk, address all-`0xFF`).
fn parse_fa_element(
file_data: &[u8],
abs: usize,
client_id: u8,
offset_size: u8,
element_size: u8,
chunk_byte_size: u64,
) -> Result<Option<(u64, u32, u32)>, FormatError> {
let os = offset_size as usize;
if client_id == 0 {
// Non-filtered: element is just the chunk address.
if abs + os > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: abs + os,
available: file_data.len(),
});
}
if is_undefined(file_data, abs, offset_size) {
return Ok(None);
}
let address = read_offset(file_data, abs, offset_size)?;
Ok(Some((address, chunk_byte_size as u32, 0)))
} else {
// Filtered: address(offset_size) + chunk_size(variable) + filter_mask(4)
let es = element_size as usize;
if es < os + 4 {
return Err(FormatError::ChunkedReadError(
"element_size too small for filtered element".into(),
));
}
let chunk_size_bytes = es - os - 4;
if abs + es > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: abs + es,
available: file_data.len(),
});
}
if is_undefined(file_data, abs, offset_size) {
return Ok(None);
}
let address = read_offset(file_data, abs, offset_size)?;
let chunk_size = read_variable_length(&file_data[abs + os..], chunk_size_bytes)?;
let fm_off = abs + os + chunk_size_bytes;
let filter_mask = u32::from_le_bytes([
file_data[fm_off],
file_data[fm_off + 1],
file_data[fm_off + 2],
file_data[fm_off + 3],
]);
Ok(Some((address, chunk_size as u32, filter_mask)))
}
}
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
fn index_to_chunk_offsets(
index: usize,
num_chunks_per_dim: &[u64],
chunk_dimensions: &[u32],
) -> Vec<u64> {
let rank = num_chunks_per_dim.len();
let mut offsets = vec![0u64; rank];
let mut remaining = index as u64;
for d in (0..rank).rev() {
let nchunks = num_chunks_per_dim[d];
if nchunks == 0 {
continue;
}
let chunk_idx = remaining % nchunks;
remaining /= nchunks;
offsets[d] = chunk_idx * chunk_dimensions[d] as u64;
}
offsets
}
/// Read a variable-length little-endian unsigned integer.
fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> {
if size > 8 || data.len() < size {
return Err(FormatError::ChunkedReadError(
"invalid variable-length size".into(),
));
}
let mut val = 0u64;
for (i, &byte) in data.iter().enumerate().take(size) {
val |= (byte as u64) << (i * 8);
}
Ok(val)
}
#[cfg(test)]
mod tests {
use super::*;
/// Stamp the Jenkins checksum a real file would carry over
/// `data[start..end]`, writing it at `end`. Fixtures built by hand need
/// this now that the reader validates it — as every HDF5 writer does.
fn stamp_checksum(data: &mut [u8], start: usize, end: usize) {
let sum = crate::checksum::jenkins_lookup3(&data[start..end]);
data[end..end + 4].copy_from_slice(&sum.to_le_bytes());
}
#[test]
fn index_to_offsets_1d() {
let num_chunks = vec![5u64];
let chunk_dims = vec![20u32];
assert_eq!(index_to_chunk_offsets(0, &num_chunks, &chunk_dims), vec![0]);
assert_eq!(
index_to_chunk_offsets(1, &num_chunks, &chunk_dims),
vec![20]
);
assert_eq!(
index_to_chunk_offsets(4, &num_chunks, &chunk_dims),
vec![80]
);
}
#[test]
fn index_to_offsets_2d() {
// 10x6 dataset with 4x3 chunks => ceil(10/4)=3, ceil(6/3)=2 => 6 chunks
let num_chunks = vec![3u64, 2];
let chunk_dims = vec![4u32, 3];
assert_eq!(
index_to_chunk_offsets(0, &num_chunks, &chunk_dims),
vec![0, 0]
);
assert_eq!(
index_to_chunk_offsets(1, &num_chunks, &chunk_dims),
vec![0, 3]
);
assert_eq!(
index_to_chunk_offsets(2, &num_chunks, &chunk_dims),
vec![4, 0]
);
assert_eq!(
index_to_chunk_offsets(3, &num_chunks, &chunk_dims),
vec![4, 3]
);
assert_eq!(
index_to_chunk_offsets(5, &num_chunks, &chunk_dims),
vec![8, 3]
);
}
#[test]
fn read_variable_length_values() {
assert_eq!(read_variable_length(&[0x78, 0x56], 2).unwrap(), 0x5678);
assert_eq!(
read_variable_length(&[0x01, 0x02, 0x03, 0x04], 4).unwrap(),
0x04030201
);
assert_eq!(read_variable_length(&[0xFF], 1).unwrap(), 0xFF);
}
#[test]
fn parse_fixed_array_header_valid() {
let mut buf = vec![0u8; 256];
// FAHD signature
buf[0..4].copy_from_slice(b"FAHD");
buf[4] = 0; // version
buf[5] = 1; // client_id = filtered
buf[6] = 16; // element_size
buf[7] = 10; // max_nelmts_bits (page_size = 1024)
// num_elements (length_size=8)
buf[8..16].copy_from_slice(&5u64.to_le_bytes());
// data_block_address (offset_size=8)
buf[16..24].copy_from_slice(&0x1000u64.to_le_bytes());
stamp_checksum(&mut buf, 0, 24);
let header = FixedArrayHeader::parse(&buf, 0, 8, 8).unwrap();
assert_eq!(header.client_id, 1);
assert_eq!(header.element_size, 16);
assert_eq!(header.max_nelmts_bits, 10);
assert_eq!(header.num_elements, 5);
assert_eq!(header.data_block_address, 0x1000);
}
/// Corruption anywhere in the index must be an error, not a wrong
/// address. Every structure carries a checksum; flipping a bit in each in
/// turn must be caught, because the alternative is reading a chunk from
/// the wrong offset and returning it as data.
#[test]
fn corrupting_any_fixed_array_structure_is_detected() {
let build = || -> (Vec<u8>, usize) {
let (os, fahd, db) = (8usize, 0x100usize, 0x200usize);
let mut f = vec![0u8; 0x3000];
f[fahd..fahd + 4].copy_from_slice(b"FAHD");
f[fahd + 6] = os as u8;
f[fahd + 7] = 10;
f[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes());
f[fahd + 16..fahd + 24].copy_from_slice(&(db as u64).to_le_bytes());
stamp_checksum(&mut f, fahd, fahd + 24);
f[db..db + 4].copy_from_slice(b"FADB");
f[db + 6..db + 14].copy_from_slice(&(fahd as u64).to_le_bytes());
let elems = db + 6 + os;
for i in 0..3usize {
let addr = 0x1000u64 + i as u64 * 0x100;
f[elems + i * os..elems + (i + 1) * os].copy_from_slice(&addr.to_le_bytes());
}
stamp_checksum(&mut f, db, elems + 3 * os);
(f, fahd)
};
let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> {
let h = FixedArrayHeader::parse(f, fahd, 8, 8)?;
read_fixed_array_chunks(f, &h, &[60], &[20], 8, 8, 8)
};
let (clean, fahd) = build();
assert!(read(&clean, fahd).is_ok(), "the intact fixture must read");
// A byte inside the header, and one inside a data block element.
for &at in &[0x108usize, 0x210usize] {
let (mut damaged, fahd) = build();
damaged[at] ^= 0x01;
assert!(
matches!(
read(&damaged, fahd),
Err(FormatError::ChecksumMismatch { .. })
),
"corruption at {at:#x} went undetected"
);
}
}
#[test]
fn parse_fixed_array_header_invalid_signature() {
let mut buf = vec![0u8; 256];
buf[0..4].copy_from_slice(b"XXXX");
let result = FixedArrayHeader::parse(&buf, 0, 8, 8);
assert!(result.is_err());
}
/// Malformed headers must error, never panic (shift overflow, huge counts).
#[test]
fn read_rejects_oversized_max_nelmts_bits() {
let mut buf = vec![0u8; 512];
let fahd = 0x40usize;
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
buf[fahd + 4] = 0; // version
buf[fahd + 5] = 0; // client_id
buf[fahd + 6] = 8; // element_size
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift
buf[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
stamp_checksum(&mut buf, fahd, fahd + 24);
// FADB so parsing reaches the paged check
let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn read_rejects_num_elements_larger_than_file() {
let mut buf = vec![0u8; 256];
let fahd = 0x40usize;
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
buf[fahd + 6] = 8;
buf[fahd + 7] = 10;
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count
buf[fahd + 16..fahd + 24].copy_from_slice(&0x80u64.to_le_bytes());
// Valid checksum, so it is the element count that must be rejected.
stamp_checksum(&mut buf, fahd, fahd + 24);
buf[0x80..0x84].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
/// A near-`usize::MAX` offset must error cleanly, not overflow/panic.
#[test]
fn parse_rejects_offset_overflow() {
let buf = vec![0u8; 64];
let result = FixedArrayHeader::parse(&buf, usize::MAX - 4, 8, 8);
assert!(result.is_err());
}
/// A near-`usize::MAX` data block address must error cleanly, not overflow/panic.
#[test]
fn read_rejects_data_block_offset_overflow() {
let header = FixedArrayHeader {
client_id: 0,
element_size: 8,
max_nelmts_bits: 10,
num_elements: 1,
data_block_address: (usize::MAX - 4) as u64,
};
let buf = vec![0u8; 64];
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn parse_fixed_array_header_invalid_version() {
let mut buf = vec![0u8; 256];
buf[0..4].copy_from_slice(b"FAHD");
buf[4] = 1; // unsupported version
let result = FixedArrayHeader::parse(&buf, 0, 8, 8);
assert!(result.is_err());
}
/// Build a synthetic Fixed Array (non-filtered) and verify reading.
#[test]
fn read_non_filtered_chunks() {
let offset_size: u8 = 8;
let length_size: u8 = 8;
let os = offset_size as usize;
let num_chunks = 5u64;
let mut file_data = vec![0u8; 0x3000];
// Build FAHD at offset 0x100
let fahd_offset = 0x100usize;
let db_offset = 0x200usize;
file_data[fahd_offset..fahd_offset + 4].copy_from_slice(b"FAHD");
file_data[fahd_offset + 4] = 0; // version
file_data[fahd_offset + 5] = 0; // client_id = non-filtered
file_data[fahd_offset + 6] = os as u8; // element_size = just address
file_data[fahd_offset + 7] = 10; // max_nelmts_bits
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
// Build FADB at db_offset
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0; // version
file_data[db_offset + 5] = 0; // client_id
file_data[db_offset + 6..db_offset + 14]
.copy_from_slice(&(fahd_offset as u64).to_le_bytes()); // header_address
// Elements: 5 addresses
let elem_start = db_offset + 6 + os;
let base_addr = 0x1000u64;
let chunk_byte_size = 20 * 8; // 20 elements × 8 bytes
for i in 0..5 {
let addr = base_addr + i as u64 * chunk_byte_size as u64;
let pos = elem_start + i * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
}
stamp_checksum(&mut file_data, db_offset, elem_start + 5 * os);
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
let ds_dims = vec![100u64];
let chunk_dims = vec![20u32];
let chunks = read_fixed_array_chunks(
&file_data,
&header,
&ds_dims,
&chunk_dims,
8,
offset_size,
length_size,
)
.unwrap();
assert_eq!(chunks.len(), 5);
for (i, c) in chunks.iter().enumerate() {
assert_eq!(c.address, base_addr + i as u64 * chunk_byte_size as u64);
assert_eq!(c.offsets, vec![i as u64 * 20]);
assert_eq!(c.filter_mask, 0);
assert_eq!(c.chunk_size, chunk_byte_size as u32);
}
}
/// Build a synthetic Fixed Array (filtered) and verify reading.
#[test]
fn read_filtered_chunks() {
let offset_size: u8 = 8;
let length_size: u8 = 8;
let os = offset_size as usize;
let num_chunks = 3u64;
// element_size for filtered: offset_size + chunk_size_bytes + 4(filter_mask)
// chunk_size_bytes: let's use 4 bytes
let chunk_size_bytes = 4usize;
let elem_size = os + chunk_size_bytes + 4;
let mut file_data = vec![0u8; 0x3000];
let fahd_offset = 0x100usize;
let db_offset = 0x200usize;
file_data[fahd_offset..fahd_offset + 4].copy_from_slice(b"FAHD");
file_data[fahd_offset + 4] = 0;
file_data[fahd_offset + 5] = 1; // client_id = filtered
file_data[fahd_offset + 6] = elem_size as u8;
file_data[fahd_offset + 7] = 10;
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0;
file_data[db_offset + 5] = 1;
file_data[db_offset + 6..db_offset + 14]
.copy_from_slice(&(fahd_offset as u64).to_le_bytes());
let elem_start = db_offset + 6 + os;
let test_chunks = [
(0x1000u64, 120u32, 0u32),
(0x2000u64, 115u32, 0u32),
(0x3000u64, 100u32, 0u32),
];
for (i, &(addr, csize, fmask)) in test_chunks.iter().enumerate() {
let pos = elem_start + i * elem_size;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
// chunk_size as 4 bytes LE
file_data[pos + os..pos + os + 4].copy_from_slice(&csize.to_le_bytes());
file_data[pos + os + 4..pos + os + 8].copy_from_slice(&fmask.to_le_bytes());
}
stamp_checksum(
&mut file_data,
db_offset,
elem_start + test_chunks.len() * elem_size,
);
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
let ds_dims = vec![60u64];
let chunk_dims = vec![20u32];
let chunks = read_fixed_array_chunks(
&file_data,
&header,
&ds_dims,
&chunk_dims,
8,
offset_size,
length_size,
)
.unwrap();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].address, 0x1000);
assert_eq!(chunks[0].chunk_size, 120);
assert_eq!(chunks[0].filter_mask, 0);
assert_eq!(chunks[0].offsets, vec![0]);
assert_eq!(chunks[1].address, 0x2000);
assert_eq!(chunks[1].chunk_size, 115);
assert_eq!(chunks[2].address, 0x3000);
assert_eq!(chunks[2].chunk_size, 100);
}
/// Build a synthetic *paged* Fixed Array (non-filtered) and verify reading.
///
/// Layout reverse-engineered and confirmed against an HDF5 2.0 file:
/// after the FADB prefix comes a page-init bitmap (MSB-first within each
/// byte), a 4-byte checksum, then full-size page slots (`page_nelmts`
/// elements + a 4-byte checksum each), with only the last page shorter.
/// Uninitialized pages occupy their slot but are skipped via the bitmap.
#[test]
fn read_paged_non_filtered_chunks() {
let offset_size: u8 = 8;
let length_size: u8 = 8;
let os = offset_size as usize;
// page_nelmts = 1 << 2 = 4. Use 11 elements => 3 pages
// (page0: 4, page1: 4, page2: 3 short). Initialize pages 0 and 2; leave
// page 1 uninitialized. 3 pages still fits one bitmap byte, but we place
// the set bits at positions 7 and 5 to lock the MSB-first ordering.
let max_nelmts_bits = 2u8;
let page_nelmts = 1usize << max_nelmts_bits; // 4
let num_elements = 11u64;
let db_header_size = 4 + 1 + 1 + os; // FADB sig+ver+client+header_addr
let bitmap_size = 1usize; // ceil(3/8)
let page_total = page_nelmts * os + 4; // elements + checksum
let fahd_offset = 0x100usize;
let db_offset = 0x400usize;
let mut file_data = vec![0u8; 0x4000];
// FAHD
file_data[fahd_offset..fahd_offset + 4].copy_from_slice(b"FAHD");
file_data[fahd_offset + 4] = 0; // version
file_data[fahd_offset + 5] = 0; // client_id = non-filtered
file_data[fahd_offset + 6] = os as u8; // element_size = address only
file_data[fahd_offset + 7] = max_nelmts_bits;
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
// FADB prefix
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0; // version
file_data[db_offset + 5] = 0; // client_id
file_data[db_offset + 6..db_offset + 6 + os]
.copy_from_slice(&(fahd_offset as u64).to_le_bytes());
// Page-init bitmap: pages 0 and 2 initialized, page 1 not.
// MSB-first => page0 -> bit7 (0x80), page2 -> bit5 (0x20) => 0xA0.
let bitmap_off = db_offset + db_header_size;
file_data[bitmap_off] = 0b1010_0000;
// Pages start after bitmap + 4-byte checksum.
let pages_start = db_offset + db_header_size + bitmap_size + 4;
let base_addr = 0x1000u64;
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses;
// page 1's slot is left zero-filled and must be skipped.
// The prefix and bitmap carry one checksum, each initialised page
// another — as a real file does.
stamp_checksum(&mut file_data, db_offset, bitmap_off + bitmap_size);
for &p in &[0usize, 2usize] {
let page_off = pages_start + p * page_total;
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
for e in 0..count {
let i = p * page_nelmts + e;
let addr = base_addr + i as u64 * 0x100;
let pos = page_off + e * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
}
stamp_checksum(&mut file_data, page_off, page_off + count * os);
}
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
assert_eq!(header.num_elements, 11);
let ds_dims = vec![11u64 * 20];
let chunk_dims = vec![20u32];
let chunks = read_fixed_array_chunks(
&file_data,
&header,
&ds_dims,
&chunk_dims,
8,
offset_size,
length_size,
)
.unwrap();
// Page 1 (elements 4,5,6,7) is uninitialized => skipped. The remaining
// 7 chunks (0..4 and 8..11) come back with their original linear index.
assert_eq!(chunks.len(), 7);
let mut got: Vec<(u64, u64)> = chunks.iter().map(|c| (c.offsets[0], c.address)).collect();
got.sort();
let expect: Vec<(u64, u64)> = [0usize, 1, 2, 3, 8, 9, 10]
.iter()
.map(|&i| (i as u64 * 20, base_addr + i as u64 * 0x100))
.collect();
assert_eq!(got, expect);
}
}