Fix silent wrong data and libhdf5 interop found by the HDF5 audit #11

Merged
osobh merged 41 commits from fix/phase0-correctness into main 2026-09-26 02:42:54 +00:00
6 changed files with 546 additions and 174 deletions
Showing only changes of commit bba1560416 - Show all commits
+201
View File
@@ -0,0 +1,201 @@
//! Chunk-index linearisation shared by the Fixed Array and Extensible Array
//! chunk indexes (reader and writer).
//!
//! Both indexes store one element per chunk at a *linear* index, and the
//! library derives that index from the chunk's scaled coordinates
//! (`offset / chunk_dim`) using the dataset's **maximum** dimensions, not its
//! current ones (`H5D__farray_idx_get_addr` / `H5D__earray_idx_get_addr`,
//! via `layout->max_down_chunks`). A dataset whose current shape is smaller
//! than its maxshape therefore has gaps in the index, and laying it out by the
//! current shape puts every chunk after the first row in the wrong place.
//!
//! The Extensible Array adds one more step: its one unlimited dimension has no
//! finite chunk count, so the library *swizzles* the coordinates to make that
//! dimension the slowest-varying one (`H5VM_swizzle_coords`, which moves
//! `coords[unlim_dim]` to the front and shifts the dimensions before it right
//! by one) before linearising with `swizzled_max_down_chunks`. When the
//! unlimited dimension is already dimension 0 no swizzle happens.
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::{vec, vec::Vec};
use crate::error::FormatError;
/// How a chunk index maps linear element indexes to chunk coordinates.
#[derive(Debug, Clone)]
pub(crate) struct ChunkGrid {
/// Spatial chunk dimensions, in dataset order.
chunk_dims: Vec<u64>,
/// Chunks per dimension covering the *current* extent, in dataset order.
cur_chunks: Vec<u64>,
/// Dataset dimension stored at each linearisation position (slowest
/// first). The identity except for a swizzled Extensible Array.
order: Vec<usize>,
/// Linear stride of each linearisation position.
down: Vec<u64>,
}
impl ChunkGrid {
/// Grid for a Fixed Array index: row-major over the chunk counts of the
/// maximum dimensions (`max_dims`, falling back to the current dimensions
/// when the dataspace records none).
pub(crate) fn fixed_array(
cur_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dims: &[u64],
) -> Result<Self, FormatError> {
Self::build(cur_dims, max_dims, chunk_dims, None)
}
/// Grid for an Extensible Array index: like the Fixed Array, but the
/// unlimited dimension (the one whose maximum is `H5S_UNLIMITED`) is moved
/// to the slowest-varying position first.
pub(crate) fn extensible_array(
cur_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dims: &[u64],
) -> Result<Self, FormatError> {
let unlim = max_dims.and_then(|m| m.iter().position(|&d| d == u64::MAX));
Self::build(cur_dims, max_dims, chunk_dims, unlim)
}
fn build(
cur_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dims: &[u64],
unlim: Option<usize>,
) -> Result<Self, FormatError> {
let rank = chunk_dims.len();
if cur_dims.len() != rank || max_dims.is_some_and(|m| m.len() != rank) {
return Err(FormatError::ChunkedReadError(
"chunk index rank does not match the dataspace".into(),
));
}
if chunk_dims.contains(&0) {
return Err(FormatError::ChunkedReadError(
"chunk dimension is zero".into(),
));
}
let cur_chunks: Vec<u64> = cur_dims
.iter()
.zip(chunk_dims)
.map(|(&d, &c)| d.div_ceil(c))
.collect();
// Chunk counts of the maximum extent. An unlimited dimension has no
// finite count; it only ever sits in the slowest position, where its
// count never enters a stride. A (corrupt) maximum smaller than the
// current extent is widened so no allocated chunk becomes unreachable.
let max_chunks: Vec<u64> = (0..rank)
.map(|d| {
let max = max_dims.map_or(cur_dims[d], |m| m[d]);
if max == u64::MAX {
u64::MAX
} else {
max.div_ceil(chunk_dims[d]).max(cur_chunks[d])
}
})
.collect();
let mut order: Vec<usize> = (0..rank).collect();
if let Some(u) = unlim {
order.remove(u);
order.insert(0, u);
}
let mut down = vec![1u64; rank];
for p in (0..rank.saturating_sub(1)).rev() {
let next = max_chunks[order[p + 1]];
if next == u64::MAX {
// Only reachable with more than one unlimited dimension, which
// neither index type can describe.
return Err(FormatError::ChunkedReadError(
"array chunk index with more than one unlimited dimension".into(),
));
}
down[p] = down[p + 1].checked_mul(next).ok_or_else(|| {
FormatError::Overflow("chunk index linear stride overflows u64".into())
})?;
}
Ok(Self {
chunk_dims: chunk_dims.to_vec(),
cur_chunks,
order,
down,
})
}
/// Dataset-space offsets of the chunk stored at linear `index`, or `None`
/// when that chunk lies outside the current extent (the index still has a
/// slot for it; the library ignores such chunks on read).
pub(crate) fn offsets(&self, index: u64) -> Option<Vec<u64>> {
let rank = self.chunk_dims.len();
let mut offsets = vec![0u64; rank];
let mut rem = index;
for p in 0..rank {
let d = self.order[p];
let scaled = rem / self.down[p];
rem %= self.down[p];
if scaled >= self.cur_chunks[d] {
return None;
}
offsets[d] = scaled * self.chunk_dims[d];
}
Some(offsets)
}
/// Linear index of the chunk with scaled coordinates `scaled`
/// (`offset / chunk_dim` per dimension, in dataset order).
#[allow(dead_code)] // used by the writer
pub(crate) fn linear_index(&self, scaled: &[u64]) -> u64 {
self.order
.iter()
.zip(&self.down)
.map(|(&d, &stride)| scaled[d] * stride)
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_array_uses_max_dims() {
// shape (4, 6), chunks (2, 3), maxshape (20, 10): 10 x 4 chunk grid.
let g = ChunkGrid::fixed_array(&[4, 6], Some(&[20, 10]), &[2, 3]).unwrap();
assert_eq!(g.offsets(0), Some(vec![0, 0]));
assert_eq!(g.offsets(1), Some(vec![0, 3]));
assert_eq!(g.offsets(2), None); // column chunk 2 is beyond the extent
assert_eq!(g.offsets(4), Some(vec![2, 0]));
assert_eq!(g.offsets(5), Some(vec![2, 3]));
assert_eq!(g.offsets(8), None); // row chunk 2 is beyond the extent
assert_eq!(g.linear_index(&[1, 1]), 5);
}
#[test]
fn extensible_array_swizzles_unlimited_dim() {
// maxshape (10, None): dim 1 is unlimited and becomes slowest.
let g = ChunkGrid::extensible_array(&[4, 6], Some(&[10, u64::MAX]), &[2, 3]).unwrap();
// max chunks of dim 0 = 5, so index = c1 * 5 + c0.
assert_eq!(g.linear_index(&[1, 0]), 1);
assert_eq!(g.linear_index(&[0, 1]), 5);
assert_eq!(g.offsets(5), Some(vec![0, 3]));
assert_eq!(g.offsets(6), Some(vec![2, 3]));
assert_eq!(g.offsets(2), None);
}
#[test]
fn extensible_array_unlimited_first_is_row_major() {
let g = ChunkGrid::extensible_array(&[4, 6], Some(&[u64::MAX, 30]), &[2, 3]).unwrap();
// max chunks of dim 1 = 10.
assert_eq!(g.linear_index(&[1, 1]), 11);
assert_eq!(g.offsets(11), Some(vec![2, 3]));
}
#[test]
fn rejects_two_unlimited_dims_after_the_first() {
assert!(ChunkGrid::fixed_array(&[4, 6], Some(&[u64::MAX, u64::MAX]), &[2, 3]).is_err());
}
}
@@ -593,6 +593,7 @@ pub fn list_chunks(
file_data, file_data,
&header, &header,
&dataspace.dimensions, &dataspace.dimensions,
dataspace.max_dimensions.as_deref(),
spatial_chunk_dims, spatial_chunk_dims,
elem_size as u32, elem_size as u32,
offset_size, offset_size,
@@ -608,6 +609,7 @@ pub fn list_chunks(
file_data, file_data,
&header, &header,
&dataspace.dimensions, &dataspace.dimensions,
dataspace.max_dimensions.as_deref(),
spatial_chunk_dims, spatial_chunk_dims,
elem_size as u32, elem_size as u32,
offset_size, offset_size,
+58 -101
View File
@@ -9,6 +9,7 @@ extern crate alloc;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use crate::chunk_grid::ChunkGrid;
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; use crate::error::FormatError;
@@ -203,8 +204,7 @@ fn read_element(
offset_size: u8, offset_size: u8,
chunk_byte_size: u64, chunk_byte_size: u64,
linear_index: usize, linear_index: usize,
num_chunks_per_dim: &[u64], grid: &ChunkGrid,
chunk_dimensions: &[u32],
) -> Result<(Option<ChunkInfo>, usize), FormatError> { ) -> Result<(Option<ChunkInfo>, usize), FormatError> {
let os = offset_size as usize; let os = offset_size as usize;
@@ -220,7 +220,10 @@ fn read_element(
return Ok((None, os)); return Ok((None, os));
} }
let address = read_offset(data, pos, offset_size)?; let address = read_offset(data, pos, offset_size)?;
let offsets = index_to_chunk_offsets(linear_index, num_chunks_per_dim, chunk_dimensions); // A slot beyond the current extent is ignored, as the library does.
let Some(offsets) = grid.offsets(linear_index as u64) else {
return Ok((None, os));
};
Ok(( Ok((
Some(ChunkInfo { Some(ChunkInfo {
chunk_size: chunk_byte_size as u32, chunk_size: chunk_byte_size as u32,
@@ -261,7 +264,9 @@ fn read_element(
data[fm_off + 2], data[fm_off + 2],
data[fm_off + 3], data[fm_off + 3],
]); ]);
let offsets = index_to_chunk_offsets(linear_index, num_chunks_per_dim, chunk_dimensions); let Some(offsets) = grid.offsets(linear_index as u64) else {
return Ok((None, elem_total));
};
Ok(( Ok((
Some(ChunkInfo { Some(ChunkInfo {
chunk_size: chunk_size as u32, chunk_size: chunk_size as u32,
@@ -274,27 +279,6 @@ fn read_element(
} }
} }
/// 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
}
/// Collect elements from a data block at the given offset. /// Collect elements from a data block at the given offset.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
/// Layout of super block `u`, per the HDF5 spec: the number of data blocks it /// Layout of super block `u`, per the HDF5 spec: the number of data blocks it
@@ -339,8 +323,7 @@ fn read_data_block_elements(
offset_size: u8, offset_size: u8,
chunk_byte_size: u64, chunk_byte_size: u64,
start_index: usize, start_index: usize,
num_chunks_per_dim: &[u64], grid: &ChunkGrid,
chunk_dimensions: &[u32],
page_init: &[u8], page_init: &[u8],
first_page: usize, first_page: usize,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
@@ -376,8 +359,7 @@ fn read_data_block_elements(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
first_index + i, first_index + i,
num_chunks_per_dim, grid,
chunk_dimensions,
)?; )?;
if let Some(ci) = info { if let Some(ci) = info {
chunks.push(ci); chunks.push(ci);
@@ -449,25 +431,19 @@ pub fn read_extensible_array_chunks(
file_data: &[u8], file_data: &[u8],
header: &ExtensibleArrayHeader, header: &ExtensibleArrayHeader,
dataset_dims: &[u64], dataset_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dimensions: &[u32], chunk_dimensions: &[u32],
element_size: u32, element_size: u32,
offset_size: u8, offset_size: u8,
_length_size: u8, _length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
let rank = chunk_dimensions.len();
let os = offset_size as usize; let os = offset_size as usize;
let mut num_chunks_per_dim = Vec::with_capacity(rank); // Linear indexes follow the maximum dimensions, with the unlimited
for d in 0..rank { // dimension swizzled to the slowest position (see `chunk_grid`).
let ch_dim = chunk_dimensions[d] as u64; let dims_u64: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect();
if ch_dim == 0 { let grid = ChunkGrid::extensible_array(dataset_dims, max_dims, &dims_u64)?;
return Err(FormatError::ChunkedReadError( let grid = &grid;
"chunk dimension is zero".into(),
));
}
let ds_dim = dataset_dims[d];
num_chunks_per_dim.push(ds_dim.div_ceil(ch_dim));
}
let chunk_byte_size: u64 = let chunk_byte_size: u64 =
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64; chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
@@ -557,8 +533,7 @@ pub fn read_extensible_array_chunks(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
i, i,
&num_chunks_per_dim, grid,
chunk_dimensions,
)?; )?;
if let Some(ci) = info { if let Some(ci) = info {
chunks.push(ci); chunks.push(ci);
@@ -594,8 +569,7 @@ pub fn read_extensible_array_chunks(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
global_index, global_index,
&num_chunks_per_dim, grid,
chunk_dimensions,
&[], &[],
0, 0,
)?); )?);
@@ -625,8 +599,7 @@ pub fn read_extensible_array_chunks(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
global_index, global_index,
&num_chunks_per_dim, grid,
chunk_dimensions,
)?); )?);
} }
global_index = global_index =
@@ -653,8 +626,7 @@ fn read_super_block(
offset_size: u8, offset_size: u8,
chunk_byte_size: u64, chunk_byte_size: u64,
start_index: usize, start_index: usize,
num_chunks_per_dim: &[u64], grid: &ChunkGrid,
chunk_dimensions: &[u32],
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
let os = offset_size as usize; let os = offset_size as usize;
let sb_header_size = 4 + 1 + 1 + os + arr_off_size(header); let sb_header_size = 4 + 1 + 1 + os + arr_off_size(header);
@@ -710,8 +682,7 @@ fn read_super_block(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
global_idx, global_idx,
num_chunks_per_dim, grid,
chunk_dimensions,
bitmap, bitmap,
i * npages, i * npages,
)?); )?);
@@ -735,35 +706,18 @@ mod tests {
} }
#[test] #[test]
fn index_to_offsets_1d() { fn index_to_offsets_1d() {
let num_chunks = vec![5u64]; let g = ChunkGrid::fixed_array(&[100], None, &[20]).unwrap();
let chunk_dims = vec![20u32]; assert_eq!(g.offsets(0).unwrap(), vec![0]);
assert_eq!(index_to_chunk_offsets(0, &num_chunks, &chunk_dims), vec![0]); assert_eq!(g.offsets(1).unwrap(), vec![20]);
assert_eq!( assert_eq!(g.offsets(4).unwrap(), vec![80]);
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] #[test]
fn index_to_offsets_2d() { fn index_to_offsets_2d() {
let num_chunks = vec![3u64, 2]; let g = ChunkGrid::fixed_array(&[10, 6], None, &[4, 3]).unwrap();
let chunk_dims = vec![4u32, 3]; assert_eq!(g.offsets(0).unwrap(), vec![0, 0]);
assert_eq!( assert_eq!(g.offsets(1).unwrap(), vec![0, 3]);
index_to_chunk_offsets(0, &num_chunks, &chunk_dims), assert_eq!(g.offsets(2).unwrap(), vec![4, 0]);
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]
);
} }
#[test] #[test]
@@ -830,7 +784,7 @@ mod tests {
index_block_address: (usize::MAX - 4) as u64, index_block_address: (usize::MAX - 4) as u64,
}; };
let buf = vec![0u8; 64]; let buf = vec![0u8; 64];
let r = read_extensible_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_extensible_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -913,9 +867,17 @@ mod tests {
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap(); let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
let ds_dims = vec![40u64]; // 2 chunks × 20 elements let ds_dims = vec![40u64]; // 2 chunks × 20 elements
let chunk_dims = vec![20u32]; let chunk_dims = vec![20u32];
let chunks = let chunks = read_extensible_array_chunks(
read_extensible_array_chunks(&file_data, &header, &ds_dims, &chunk_dims, 8, os, ls) &file_data,
.unwrap(); &header,
&ds_dims,
None,
&chunk_dims,
8,
os,
ls,
)
.unwrap();
assert_eq!(chunks.len(), 2); assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].address, base_addr); assert_eq!(chunks[0].address, base_addr);
@@ -1023,9 +985,17 @@ mod tests {
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap(); let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
let ds_dims = vec![40u64]; let ds_dims = vec![40u64];
let chunk_dims = vec![10u32]; let chunk_dims = vec![10u32];
let chunks = let chunks = read_extensible_array_chunks(
read_extensible_array_chunks(&file_data, &header, &ds_dims, &chunk_dims, 8, os, ls) &file_data,
.unwrap(); &header,
&ds_dims,
None,
&chunk_dims,
8,
os,
ls,
)
.unwrap();
assert_eq!(chunks.len(), 4); assert_eq!(chunks.len(), 4);
for (i, c) in chunks.iter().enumerate() { for (i, c) in chunks.iter().enumerate() {
@@ -1047,10 +1017,8 @@ mod tests {
#[test] #[test]
fn read_element_unallocated() { fn read_element_unallocated() {
let data = vec![0xFFu8; 16]; let data = vec![0xFFu8; 16];
let num_chunks = vec![5u64]; let grid = ChunkGrid::fixed_array(&[50], None, &[10]).unwrap();
let chunk_dims = vec![10u32]; let (info, consumed) = read_element(&data, 0, 0, 8, 8, 80, 0, &grid).unwrap();
let (info, consumed) =
read_element(&data, 0, 0, 8, 8, 80, 0, &num_chunks, &chunk_dims).unwrap();
assert!(info.is_none()); assert!(info.is_none());
assert_eq!(consumed, 8); assert_eq!(consumed, 8);
} }
@@ -1069,20 +1037,9 @@ mod tests {
// Filter mask // Filter mask
data[12..16].copy_from_slice(&0u32.to_le_bytes()); data[12..16].copy_from_slice(&0u32.to_le_bytes());
let num_chunks = vec![5u64]; let grid = ChunkGrid::fixed_array(&[50], None, &[10]).unwrap();
let chunk_dims = vec![10u32]; let (info, consumed) =
let (info, consumed) = read_element( read_element(&data, 0, 1, elem_size as u8, os, 80, 2, &grid).unwrap();
&data,
0,
1,
elem_size as u8,
os,
80,
2,
&num_chunks,
&chunk_dims,
)
.unwrap();
let ci = info.unwrap(); let ci = info.unwrap();
assert_eq!(ci.address, 0x2000); assert_eq!(ci.address, 0x2000);
assert_eq!(ci.chunk_size, 120); assert_eq!(ci.chunk_size, 120);
+28 -73
View File
@@ -6,6 +6,7 @@ extern crate alloc;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use crate::chunk_grid::ChunkGrid;
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; use crate::error::FormatError;
@@ -151,13 +152,13 @@ pub fn read_fixed_array_chunks(
file_data: &[u8], file_data: &[u8],
header: &FixedArrayHeader, header: &FixedArrayHeader,
dataset_dims: &[u64], dataset_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dimensions: &[u32], chunk_dimensions: &[u32],
element_size: u32, element_size: u32,
offset_size: u8, offset_size: u8,
_length_size: u8, _length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
let db_offset = header.data_block_address as usize; 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) // 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; let db_header_size = 4 + 1 + 1 + offset_size as usize;
@@ -198,19 +199,10 @@ pub fn read_fixed_array_chunks(
)) ))
}; };
// Compute chunk offsets based on index. // The index is laid out over the chunk grid of the *maximum* dimensions
// Chunks are stored in row-major order within the dataset space. // (row-major), so a dataset smaller than its maxshape has gaps.
let mut num_chunks_per_dim = Vec::with_capacity(rank); let dims_u64: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect();
for d_idx in 0..rank { let grid = ChunkGrid::fixed_array(dataset_dims, max_dims, &dims_u64)?;
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 = let chunk_byte_size: u64 =
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64; chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
@@ -226,7 +218,11 @@ pub fn read_fixed_array_chunks(
header.element_size, header.element_size,
chunk_byte_size, chunk_byte_size,
)? { )? {
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions); // A slot beyond the current extent is ignored, as the
// library does.
let Some(offsets) = grid.offsets(i as u64) else {
return Ok(());
};
chunks.push(ChunkInfo { chunks.push(ChunkInfo {
chunk_size, chunk_size,
filter_mask, filter_mask,
@@ -367,27 +363,6 @@ fn parse_fa_element(
} }
} }
/// 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. /// Read a variable-length little-endian unsigned integer.
fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> { fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> {
if size > 8 || data.len() < size { if size > 8 || data.len() < size {
@@ -416,44 +391,21 @@ mod tests {
#[test] #[test]
fn index_to_offsets_1d() { fn index_to_offsets_1d() {
let num_chunks = vec![5u64]; let g = ChunkGrid::fixed_array(&[100], None, &[20]).unwrap();
let chunk_dims = vec![20u32]; assert_eq!(g.offsets(0).unwrap(), vec![0]);
assert_eq!(index_to_chunk_offsets(0, &num_chunks, &chunk_dims), vec![0]); assert_eq!(g.offsets(1).unwrap(), vec![20]);
assert_eq!( assert_eq!(g.offsets(4).unwrap(), vec![80]);
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] #[test]
fn index_to_offsets_2d() { fn index_to_offsets_2d() {
// 10x6 dataset with 4x3 chunks => ceil(10/4)=3, ceil(6/3)=2 => 6 chunks // 10x6 dataset with 4x3 chunks => ceil(10/4)=3, ceil(6/3)=2 => 6 chunks
let num_chunks = vec![3u64, 2]; let g = ChunkGrid::fixed_array(&[10, 6], None, &[4, 3]).unwrap();
let chunk_dims = vec![4u32, 3]; assert_eq!(g.offsets(0).unwrap(), vec![0, 0]);
assert_eq!( assert_eq!(g.offsets(1).unwrap(), vec![0, 3]);
index_to_chunk_offsets(0, &num_chunks, &chunk_dims), assert_eq!(g.offsets(2).unwrap(), vec![4, 0]);
vec![0, 0] assert_eq!(g.offsets(3).unwrap(), vec![4, 3]);
); assert_eq!(g.offsets(5).unwrap(), vec![8, 3]);
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] #[test]
@@ -517,7 +469,7 @@ mod tests {
let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> { let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> {
let h = FixedArrayHeader::parse(f, fahd, 8, 8)?; let h = FixedArrayHeader::parse(f, fahd, 8, 8)?;
read_fixed_array_chunks(f, &h, &[60], &[20], 8, 8, 8) read_fixed_array_chunks(f, &h, &[60], None, &[20], 8, 8, 8)
}; };
let (clean, fahd) = build(); let (clean, fahd) = build();
@@ -562,7 +514,7 @@ mod tests {
let db = 0x100usize; let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB"); buf[db..db + 4].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap(); let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_fixed_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -579,7 +531,7 @@ mod tests {
stamp_checksum(&mut buf, fahd, fahd + 24); stamp_checksum(&mut buf, fahd, fahd + 24);
buf[0x80..0x84].copy_from_slice(b"FADB"); buf[0x80..0x84].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap(); let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_fixed_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -602,7 +554,7 @@ mod tests {
data_block_address: (usize::MAX - 4) as u64, data_block_address: (usize::MAX - 4) as u64,
}; };
let buf = vec![0u8; 64]; let buf = vec![0u8; 64];
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_fixed_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -664,6 +616,7 @@ mod tests {
&file_data, &file_data,
&header, &header,
&ds_dims, &ds_dims,
None,
&chunk_dims, &chunk_dims,
8, 8,
offset_size, offset_size,
@@ -740,6 +693,7 @@ mod tests {
&file_data, &file_data,
&header, &header,
&ds_dims, &ds_dims,
None,
&chunk_dims, &chunk_dims,
8, 8,
offset_size, offset_size,
@@ -840,6 +794,7 @@ mod tests {
&file_data, &file_data,
&header, &header,
&ds_dims, &ds_dims,
None,
&chunk_dims, &chunk_dims,
8, 8,
offset_size, offset_size,
+1
View File
@@ -54,6 +54,7 @@ pub mod btree_v1;
pub mod btree_v2; pub mod btree_v2;
pub mod checksum; pub mod checksum;
pub mod chunk_cache; pub mod chunk_cache;
mod chunk_grid;
pub mod chunk_index; pub mod chunk_index;
pub mod chunked_read; pub mod chunked_read;
pub mod chunked_write; pub mod chunked_write;
@@ -0,0 +1,256 @@
//! Fixed Array / Extensible Array chunk-index interop with libhdf5 (via h5py).
//!
//! Both indexes place each chunk at a linear index computed from the
//! dataset's *maximum* dimensions, and the Extensible Array additionally
//! moves its unlimited dimension to the slowest-varying position. Getting
//! either wrong reads (or writes) every chunk after the first row in the
//! wrong place, silently, so these tests compare every value.
//!
//! Skipped when python3 with h5py is unavailable, unless
//! `CLAWHDF5_REQUIRE_INTEROP=1`.
use std::process::Command;
use clawhdf5::File;
fn python() -> String {
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
fn interop_required() -> bool {
std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1")
}
fn python_available() -> bool {
Command::new(python())
.args(["-c", "import h5py"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
macro_rules! skip_if_no_python {
() => {
if !python_available() {
assert!(
!interop_required(),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
);
eprintln!("SKIP: python3 with h5py not available");
return;
}
};
}
fn run_python(script: &str) -> String {
let output = Command::new(python())
.args(["-c", script])
.output()
.expect("failed to run python");
if !output.status.success() {
panic!(
"Python script failed:\nSTDOUT: {}\nSTDERR: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
/// Row-major `arange` of `shape`, cropped to `crop` (the current extent).
fn arange_cropped(full: &[usize], crop: &[usize]) -> Vec<i32> {
let n: usize = crop.iter().product();
let mut out = Vec::with_capacity(n);
for flat in 0..n {
let mut rem = flat;
let mut src = 0usize;
let mut stride = 1usize;
let mut coords = vec![0usize; crop.len()];
for d in (0..crop.len()).rev() {
coords[d] = rem % crop[d];
rem /= crop[d];
}
for d in (0..full.len()).rev() {
src += coords[d] * stride;
stride *= full[d];
}
out.push(src as i32);
}
out
}
/// One `i4` dataset, filled with `arange` over `full` and then resized to
/// `shape` (equal to `full` unless the case shrinks it).
struct Case {
name: &'static str,
full: Vec<usize>,
shape: Vec<usize>,
chunks: Vec<usize>,
maxshape: &'static str,
extra: &'static str,
index: &'static str,
}
fn py_tuple(v: &[usize]) -> String {
let parts: Vec<String> = v.iter().map(|x| x.to_string()).collect();
format!("({},)", parts.join(","))
}
/// Have h5py (`libver="latest"`, so Fixed/Extensible Array indexes) write
/// every case to one file, then read each back and compare every value.
fn check_h5py_written(cases: &[Case]) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("h5py_chunk_index.h5");
let path_str = path.display().to_string();
let mut script =
format!("import h5py, numpy as np\nf = h5py.File(r'{path_str}', 'w', libver='latest')\n");
for c in cases {
script += &format!(
"d = f.create_dataset('{name}', data=np.arange({n}, dtype='i4').reshape({full}), \
chunks={chunks}, maxshape={maxshape}{extra})\n\
d.resize({shape})\n",
name = c.name,
n = c.full.iter().product::<usize>(),
full = py_tuple(&c.full),
chunks = py_tuple(&c.chunks),
maxshape = c.maxshape,
extra = c.extra,
shape = py_tuple(&c.shape),
);
}
script += "f.close()\n";
run_python(&script);
let file = File::open(&path).unwrap();
for c in cases {
let ds = file.dataset(c.name).unwrap();
let shape: Vec<usize> = ds.shape().unwrap().iter().map(|&d| d as usize).collect();
assert_eq!(shape, c.shape, "{}: shape", c.name);
let got = ds.read_i32().unwrap();
let want = arange_cropped(&c.full, &c.shape);
let bad = got.iter().zip(&want).filter(|(a, b)| a != b).count();
assert_eq!(
got,
want,
"{}: {bad} of {} values differ (index {})",
c.name,
want.len(),
c.index
);
}
}
/// h5py-written Extensible Array whose unlimited dimension is not the first,
/// with the current shape smaller than the finite maximum: the library
/// swizzles the unlimited dimension to the slowest position and strides the
/// rest by their maximum chunk counts.
#[test]
fn h5py_extensible_array_partial_extent_reads_correctly() {
skip_if_no_python!();
check_h5py_written(&[
// The `ea_fa_partial.h5` repro from the conformance sweep.
Case {
name: "ea_10_none",
full: vec![4, 6],
shape: vec![4, 6],
chunks: vec![2, 3],
maxshape: "(10, None)",
extra: "",
index: "EA, unlimited dim 1",
},
Case {
name: "ea_none_10",
full: vec![4, 6],
shape: vec![4, 6],
chunks: vec![2, 3],
maxshape: "(None, 10)",
extra: "",
index: "EA, unlimited dim 0",
},
Case {
name: "ea_3d_mid",
full: vec![3, 4, 5],
shape: vec![3, 4, 5],
chunks: vec![2, 3, 2],
maxshape: "(5, None, 7)",
extra: "",
index: "EA, unlimited dim 1 of 3",
},
Case {
name: "ea_3d_last_gzip",
full: vec![3, 4, 5],
shape: vec![3, 4, 5],
chunks: vec![2, 3, 2],
maxshape: "(5, 9, None)",
extra: ", compression='gzip'",
index: "EA, unlimited dim 2 of 3, filtered",
},
// Many chunks: crosses data blocks, super blocks and paging.
Case {
name: "ea_many",
full: vec![3, 1500],
shape: vec![3, 1500],
chunks: vec![1, 1],
maxshape: "(4, None)",
extra: "",
index: "EA, 4500 slots",
},
// Shrunk after writing: chunks beyond the extent must be ignored.
Case {
name: "ea_shrunk",
full: vec![8, 9],
shape: vec![3, 4],
chunks: vec![2, 3],
maxshape: "(10, None)",
extra: "",
index: "EA, shrunk",
},
]);
}
/// h5py-written Fixed Array with the current shape smaller than a finite
/// maxshape: the index has one slot per chunk of the *maximum* extent.
#[test]
fn h5py_fixed_array_partial_extent_reads_correctly() {
skip_if_no_python!();
check_h5py_written(&[
Case {
name: "fa_20_10",
full: vec![4, 6],
shape: vec![4, 6],
chunks: vec![2, 3],
maxshape: "(20, 10)",
extra: "",
index: "FA",
},
Case {
name: "fa_3d_gzip",
full: vec![3, 4, 5],
shape: vec![3, 4, 5],
chunks: vec![2, 3, 2],
maxshape: "(6, 8, 10)",
extra: ", compression='gzip'",
index: "FA, filtered",
},
// Paged (> 1024 slots) with most of them beyond the extent.
Case {
name: "fa_paged",
full: vec![30, 50],
shape: vec![30, 50],
chunks: vec![1, 1],
maxshape: "(40, 60)",
extra: "",
index: "FA, 2400 slots, paged",
},
Case {
name: "fa_shrunk",
full: vec![8, 9],
shape: vec![5, 2],
chunks: vec![2, 3],
maxshape: "(20, 10)",
extra: "",
index: "FA, shrunk",
},
]);
}