Merge fix/array-checksums: chunk index checksums are verified
CI / test (push) Failing after 2s

Fixed and Extensible Array structures all carry a Jenkins checksum that
was ignored. A single flipped bit in a chunk address parses cleanly and
points inside the file, so without the check the reader returns another
chunk's bytes as data.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-20 17:41:48 -07:00
co-authored by Claude Opus 5
4 changed files with 349 additions and 61 deletions
+13
View File
@@ -2,6 +2,19 @@
## Unreleased ## Unreleased
### Integrity
- `clawhdf5-format`: **Fixed and Extensible Array chunk indexes now verify
their checksums** (the `checksum` feature, on by default). Every structure
in both — header, index block, super block, data block and each data block
page — carries a Jenkins lookup3 checksum that was parsed past and ignored.
The consequence of skipping it is not a missing warning but wrong data: a
single flipped bit in a chunk address still parses, still points inside the
file, and the reader hands back whatever bytes now sit there as the chunk's
contents. Verified in both directions — the checksums accept files written
by HDF5 2.0 at 100 to 200 000 chunks, dense, sparse, filtered and paged,
and an interop test corrupts an address to confirm the read now fails
instead of returning data (it does return data when the check is removed).
### Performance ### Performance
- `clawhdf5-accel`: **`dot_i8`, a runtime-dispatched int8 dot product** (AVX2: - `clawhdf5-accel`: **`dot_i8`, a runtime-dispatched int8 dot product** (AVX2:
sign-extend each half to `i16`, then `madd_epi16`; scalar fallback sign-extend each half to `i16`, then `madd_epi16`; scalar fallback
+161 -59
View File
@@ -12,6 +12,31 @@ use alloc::{format, vec, vec::Vec};
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; use crate::error::FormatError;
/// Verify the Jenkins lookup3 checksum stored immediately after
/// `data[start..end]`, as every Extensible Array structure carries one.
///
/// A corrupt chunk index yields addresses pointing at the wrong bytes, so a
/// mismatch is an error: otherwise the damage surfaces as plausible data read
/// 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 Extensible Array header (AEHD). /// Parsed Extensible Array header (AEHD).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ExtensibleArrayHeader { pub struct ExtensibleArrayHeader {
@@ -145,6 +170,8 @@ impl ExtensibleArrayHeader {
pos += ls; // skip nelmts pos += ls; // skip nelmts
pos += ls; // skip max_idx_set (6th stats field) pos += ls; // skip max_idx_set (6th stats field)
let index_block_address = read_offset(d, pos, offset_size)?; let index_block_address = read_offset(d, pos, offset_size)?;
pos += offset_size as usize;
verify_checksum(file_data, offset, offset + pos)?;
Ok(ExtensibleArrayHeader { Ok(ExtensibleArrayHeader {
client_id, client_id,
@@ -361,6 +388,17 @@ fn read_data_block_elements(
}; };
if nelmts <= page { if nelmts <= page {
// Prefix and elements are covered by one checksum.
let elem_bytes = if header.client_id == 0 {
offset_size as usize
} else {
header.element_size as usize
};
let end = nelmts
.checked_mul(elem_bytes)
.and_then(|b| pos.checked_add(b))
.ok_or_else(|| FormatError::Overflow("Extensible Array data block span".into()))?;
verify_checksum(file_data, db_offset, end)?;
read_run(pos, nelmts, start_index, &mut chunks)?; read_run(pos, nelmts, start_index, &mut chunks)?;
return Ok(chunks); return Ok(chunks);
} }
@@ -369,6 +407,7 @@ fn read_data_block_elements(
// each holding `page` elements followed by a checksum. Pages whose bit is // each holding `page` elements followed by a checksum. Pages whose bit is
// clear were never written; their slot still occupies the file, so stride // clear were never written; their slot still occupies the file, so stride
// over it rather than reading zeros as addresses. // over it rather than reading zeros as addresses.
verify_checksum(file_data, db_offset, pos)?;
pos += 4; pos += 4;
let elem_bytes = if header.client_id == 0 { let elem_bytes = if header.client_id == 0 {
offset_size as usize offset_size as usize
@@ -389,6 +428,9 @@ fn read_data_block_elements(
.is_some_and(|byte| byte & (0x80 >> (bit % 8)) != 0); .is_some_and(|byte| byte & (0x80 >> (bit % 8)) != 0);
if initialised { if initialised {
let count = core::cmp::min(page, nelmts - p * page); let count = core::cmp::min(page, nelmts - p * page);
// Each page carries its own checksum, over a full page's worth of
// slots even when the last one holds fewer live elements.
verify_checksum(file_data, pos, pos + page * elem_bytes)?;
read_run(pos, count, start_index + p * page, &mut chunks)?; read_run(pos, count, start_index + p * page, &mut chunks)?;
} }
pos = pos pos = pos
@@ -447,6 +489,63 @@ pub fn read_extensible_array_chunks(
let mut chunks = Vec::new(); let mut chunks = Vec::new();
let total_elements = header.num_elements as usize; let total_elements = header.num_elements as usize;
let dmin = header.min_dblk_nelmts as usize;
if dmin == 0 || !dmin.is_power_of_two() {
return Err(FormatError::ChunkedReadError(
"Extensible Array data block minimum is not a power of two".into(),
));
}
// nsblks = 1 + (max_nelmts_bits - log2(data_blk_min_elmts)), and the index
// block holds 2 * (sup_blk_min_data_ptrs - 1) data block addresses.
let log2_dmin = dmin.trailing_zeros() as usize;
let nsblks = 1 + (header.max_nelmts_bits as usize).saturating_sub(log2_dmin);
let ndblk_addrs = 2 * (header.super_blk_min_nelmts as usize).saturating_sub(1);
// The data blocks listed directly in the index block are the first
// `ndblk_addrs` in super-block order, each sized by the level it belongs
// to; the super block addresses that follow resume at the next level.
let mut direct: Vec<usize> = Vec::with_capacity(ndblk_addrs);
let mut level = 0usize;
while direct.len() < ndblk_addrs {
if level >= nsblks {
return Err(FormatError::ChunkedReadError(
"Extensible Array index block claims more data blocks than the array has".into(),
));
}
let (ndblks, dblk_nelmts) = sblk_info(level, dmin).ok_or_else(|| {
FormatError::Overflow("Extensible Array super block layout overflows usize".into())
})?;
for _ in 0..ndblks {
direct.push(dblk_nelmts);
}
level += 1;
}
if direct.len() != ndblk_addrs {
// A partial level in the index block is not a layout HDF5 produces,
// and guessing where the super blocks resume would misplace elements.
return Err(FormatError::ChunkedReadError(
"Extensible Array index block ends mid super block".into(),
));
}
// One checksum covers the prefix, every inline element slot, and every
// data block and super block address.
let elem_bytes = if header.client_id == 0 {
os
} else {
header.element_size as usize
};
let ib_end = (header.idx_blk_elmts as usize)
.checked_mul(elem_bytes)
.and_then(|b| pos.checked_add(b))
.and_then(|p| {
ndblk_addrs
.checked_add(nsblks - level)
.and_then(|n| n.checked_mul(os).and_then(|b| p.checked_add(b)))
})
.ok_or_else(|| FormatError::Overflow("Extensible Array index block span".into()))?;
verify_checksum(file_data, ib_offset, ib_end)?;
// 1. Elements stored inline in the index block. // 1. Elements stored inline in the index block.
let n_inline = (header.idx_blk_elmts as usize).min(total_elements); let n_inline = (header.idx_blk_elmts as usize).min(total_elements);
for i in 0..n_inline { for i in 0..n_inline {
@@ -471,45 +570,7 @@ pub fn read_extensible_array_chunks(
return Ok(chunks); return Ok(chunks);
} }
let dmin = header.min_dblk_nelmts as usize; // 2. Data blocks listed directly in the index block.
if dmin == 0 || !dmin.is_power_of_two() {
return Err(FormatError::ChunkedReadError(
"Extensible Array data block minimum is not a power of two".into(),
));
}
// nsblks = 1 + (max_nelmts_bits - log2(data_blk_min_elmts)), and the index
// block holds 2 * (sup_blk_min_data_ptrs - 1) data block addresses.
let log2_dmin = dmin.trailing_zeros() as usize;
let nsblks = 1 + (header.max_nelmts_bits as usize).saturating_sub(log2_dmin);
let ndblk_addrs = 2 * (header.super_blk_min_nelmts as usize).saturating_sub(1);
// 2. Data blocks listed directly in the index block: the first
// `ndblk_addrs` data blocks in super-block order, each sized by the
// level it belongs to.
let mut direct: Vec<usize> = Vec::with_capacity(ndblk_addrs);
let mut level = 0usize;
while direct.len() < ndblk_addrs {
let (ndblks, dblk_nelmts) = sblk_info(level, dmin).ok_or_else(|| {
FormatError::Overflow("Extensible Array super block layout overflows usize".into())
})?;
if level >= nsblks {
return Err(FormatError::ChunkedReadError(
"Extensible Array index block claims more data blocks than the array has".into(),
));
}
for _ in 0..ndblks {
direct.push(dblk_nelmts);
}
level += 1;
}
if direct.len() != ndblk_addrs {
// A partial level in the index block is not a layout HDF5 produces,
// and guessing where the super blocks resume would misplace elements.
return Err(FormatError::ChunkedReadError(
"Extensible Array index block ends mid super block".into(),
));
}
for &dblk_nelmts in &direct { for &dblk_nelmts in &direct {
if global_index >= total_elements { if global_index >= total_elements {
return Ok(chunks); return Ok(chunks);
@@ -629,6 +690,13 @@ fn read_super_block(
let mut chunks = Vec::new(); let mut chunks = Vec::new();
let mut global_idx = start_index; let mut global_idx = start_index;
// One checksum covers the prefix, the bitmap and every data block address.
let sb_end = ndblks
.checked_mul(os)
.and_then(|b| pos.checked_add(b))
.ok_or_else(|| FormatError::Overflow("Extensible Array super block span".into()))?;
verify_checksum(file_data, sb_offset, sb_end)?;
for i in 0..ndblks { for i in 0..ndblks {
ensure_len(file_data, pos, os)?; ensure_len(file_data, pos, os)?;
let addr = read_offset(file_data, pos, offset_size)?; let addr = read_offset(file_data, pos, offset_size)?;
@@ -657,6 +725,14 @@ fn read_super_block(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Stamp the Jenkins checksum a real file would carry over
/// `data[start..end]`, writing it at `end`. Hand-built fixtures need this
/// now that the reader validates it, exactly as HDF5 writes it.
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] #[test]
fn index_to_offsets_1d() { fn index_to_offsets_1d() {
let num_chunks = vec![5u64]; let num_chunks = vec![5u64];
@@ -712,6 +788,7 @@ mod tests {
buf[44..52].copy_from_slice(&5u64.to_le_bytes()); // stat[4] = num_elements buf[44..52].copy_from_slice(&5u64.to_le_bytes()); // stat[4] = num_elements
buf[52..60].copy_from_slice(&0u64.to_le_bytes()); // stat[5] buf[52..60].copy_from_slice(&0u64.to_le_bytes()); // stat[5]
buf[60..68].copy_from_slice(&0x1000u64.to_le_bytes()); // index_block_address buf[60..68].copy_from_slice(&0x1000u64.to_le_bytes()); // index_block_address
stamp_checksum(&mut buf, 0, 68);
let hdr = ExtensibleArrayHeader::parse(&buf, 0, os, ls).unwrap(); let hdr = ExtensibleArrayHeader::parse(&buf, 0, os, ls).unwrap();
assert_eq!(hdr.client_id, 0); assert_eq!(hdr.client_id, 0);
@@ -797,6 +874,7 @@ mod tests {
.copy_from_slice(&(num_chunks as u64).to_le_bytes()); .copy_from_slice(&(num_chunks as u64).to_le_bytes());
file_data[aehd_offset + 60..aehd_offset + 68] file_data[aehd_offset + 60..aehd_offset + 68]
.copy_from_slice(&(aeib_offset as u64).to_le_bytes()); .copy_from_slice(&(aeib_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, aehd_offset, aehd_offset + 68);
// checksum (4 bytes at +68) — not validated // checksum (4 bytes at +68) — not validated
// Build AEIB at aeib_offset // Build AEIB at aeib_offset
@@ -814,6 +892,23 @@ mod tests {
let p = elem_start + i * osv; let p = elem_start + i * osv;
file_data[p..p + osv].copy_from_slice(&addr.to_le_bytes()); file_data[p..p + osv].copy_from_slice(&addr.to_le_bytes());
} }
// The index block's checksum covers its prefix, every inline element
// slot, and every data block and super block address slot:
// ndblk_addrs = 2 * (sup_blk_min_data_ptrs - 1), and the super block
// pointers make up the rest of nsblks levels.
let sup_ptrs = file_data[aehd_offset + 10] as usize;
let dmin = file_data[aehd_offset + 9] as usize;
let nsblks = 1 + 10 - dmin.trailing_zeros() as usize;
let ndblk_addrs = 2 * (sup_ptrs - 1);
// Levels consumed by those direct data blocks (1, 1, 2, 2, ... per level).
let mut consumed = 0usize;
let mut levels = 0usize;
while consumed < ndblk_addrs {
consumed += 1 << (levels / 2);
levels += 1;
}
let ib_end = elem_start + num_chunks * osv + (ndblk_addrs + nsblks - levels) * osv;
stamp_checksum(&mut file_data, aeib_offset, ib_end);
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
@@ -863,6 +958,7 @@ mod tests {
// idx_blk_addr at offset 12 + 6*8 = 60 // idx_blk_addr at offset 12 + 6*8 = 60
file_data[aehd_offset + 60..aehd_offset + 68] file_data[aehd_offset + 60..aehd_offset + 68]
.copy_from_slice(&(aeib_offset as u64).to_le_bytes()); .copy_from_slice(&(aeib_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, aehd_offset, aehd_offset + 68);
// AEIB // AEIB
file_data[aeib_offset..aeib_offset + 4].copy_from_slice(b"EAIB"); file_data[aeib_offset..aeib_offset + 4].copy_from_slice(b"EAIB");
@@ -881,42 +977,48 @@ mod tests {
pos += osv; pos += osv;
} }
// Direct data block addresses: first sb_level=0 has 1 dblk, sb_level=1 has 1 dblk // Direct data block addresses. With sup_blk_min_data_ptrs = 2 the index
// Total direct dblks for sblk_min=2: 2^0 + 2^1 = 1 + 2 = 3 (oops) // block holds 2 * (2 - 1) = 2 of them, which are the data blocks of
// Actually: sblk_min levels. level 0: 2^0=1 dblk, level 1: 2^1=2 dblks => 3 dblks // super block levels 0 and 1: one of `min_dblk_nelmts` elements, then
// But we only have 2 remaining elements. // one of twice that (ndblks = 2^(u/2), dblk_nelmts = 2^((u+1)/2) * min).
// dblk sizes: level 0: 1 dblk of min_dblk=2; level 1: 2 dblks of 2 each (nelmts doubles at level > 0) // Only the first is allocated here; the rest of the array is empty.
// Wait, re-reading the code: at level 0, nelmts=min_dblk=2, 1 dblk. let ndblk_addrs = 2 * (sblk_min as usize - 1);
// At level 1, 1 dblk, nelmts still 2 (doubles only at level > 0... but the code says
// `if sb_level > 0 { nelmts *= 2 }` after pushing). Let me re-check.
// After push at level 0: nelmts=2. Then if 0>0 false, no double. Push 1 dblk of 2.
// Level 1: ndblks=2. Push 2 dblks of 2. Then 1>0 true, nelmts=4.
// Total: 3 dblks with sizes [2, 2, 2]. Total = 6.
// We only need 2 more elements. So only the first dblk has data.
let n_direct_dblks = 3;
file_data[pos..pos + osv].copy_from_slice(&(aedb_offset as u64).to_le_bytes()); file_data[pos..pos + osv].copy_from_slice(&(aedb_offset as u64).to_le_bytes());
pos += osv; pos += osv;
// 2 more dblk addresses - undefined for _ in 1..ndblk_addrs {
for _ in 1..n_direct_dblks {
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes()); file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
pos += osv; pos += osv;
} }
// Super block addresses fill the remaining levels; all unallocated.
let nsblks = 1 + 10 - (min_dblk_nelmts as usize).trailing_zeros() as usize;
let mut consumed = 0usize;
let mut levels = 0usize;
while consumed < ndblk_addrs {
consumed += 1 << (levels / 2);
levels += 1;
}
for _ in 0..(nsblks - levels) {
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
pos += osv;
}
stamp_checksum(&mut file_data, aeib_offset, pos);
// EADB at aedb_offset (min_dblk_nelmts elements) // EADB holding the first data block's `min_dblk_nelmts` elements.
file_data[aedb_offset..aedb_offset + 4].copy_from_slice(b"EADB"); file_data[aedb_offset..aedb_offset + 4].copy_from_slice(b"EADB");
file_data[aedb_offset + 4] = 0; file_data[aedb_offset + 4] = 0;
file_data[aedb_offset + 5] = 0; file_data[aedb_offset + 5] = 0;
file_data[aedb_offset + 6..aedb_offset + 14] file_data[aedb_offset + 6..aedb_offset + 14]
.copy_from_slice(&(aehd_offset as u64).to_le_bytes()); .copy_from_slice(&(aehd_offset as u64).to_le_bytes());
// block_offset: ceil(max_nelmts_bits/8) = ceil(10/8) = 2 bytes // Block offset field: ceil(max_nelmts_bits / 8) bytes, zero here.
// block_offset = 0 for first data block let blk_off_size = (10usize).div_ceil(8);
let blk_off_size = (10usize).div_ceil(8); // max_nelmts_bits=10 let db_elems = aedb_offset + 6 + osv + blk_off_size;
let mut dbpos = aedb_offset + 6 + osv + blk_off_size; let mut dbpos = db_elems;
for i in 0..min_dblk_nelmts as usize { for i in 0..min_dblk_nelmts as usize {
let addr = base_addr + (idx_blk_elmts as u64 + i as u64) * chunk_byte_size; let addr = base_addr + (idx_blk_elmts as u64 + i as u64) * chunk_byte_size;
file_data[dbpos..dbpos + osv].copy_from_slice(&addr.to_le_bytes()); file_data[dbpos..dbpos + osv].copy_from_slice(&addr.to_le_bytes());
dbpos += osv; dbpos += osv;
} }
stamp_checksum(&mut file_data, aedb_offset, dbpos);
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];
+106 -2
View File
@@ -9,6 +9,31 @@ use alloc::{format, vec, vec::Vec};
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; 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). /// Parsed Fixed Array header (FAHD).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FixedArrayHeader { pub struct FixedArrayHeader {
@@ -103,6 +128,8 @@ impl FixedArrayHeader {
let num_elements = read_length(d, pos, length_size)?; let num_elements = read_length(d, pos, length_size)?;
pos += length_size as usize; pos += length_size as usize;
let data_block_address = read_offset(d, pos, offset_size)?; let data_block_address = read_offset(d, pos, offset_size)?;
pos += offset_size as usize;
verify_checksum(file_data, offset, offset + pos)?;
Ok(FixedArrayHeader { Ok(FixedArrayHeader {
client_id, client_id,
@@ -223,7 +250,8 @@ pub fn read_fixed_array_chunks(
if !is_paged { if !is_paged {
// Non-paged: prefix, then `num_elements` elements packed directly, // Non-paged: prefix, then `num_elements` elements packed directly,
// then a trailing checksum (which we don't validate). // then a checksum over both.
verify_checksum(file_data, db_offset, elem_at(elements_start, num_elements)?)?;
for i in 0..num_elements { for i in 0..num_elements {
push_element(i, elem_at(elements_start, i)?, &mut chunks)?; push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
} }
@@ -254,6 +282,9 @@ pub fn read_fixed_array_chunks(
available: file_data.len(), 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 { for p in 0..npages {
let page_first = p * page_nelmts; // < num_elements, cannot overflow let page_first = p * page_nelmts; // < num_elements, cannot overflow
@@ -270,6 +301,7 @@ pub fn read_fixed_array_chunks(
.checked_mul(page_stride) .checked_mul(page_stride)
.and_then(|o| pages_start.checked_add(o)) .and_then(|o| pages_start.checked_add(o))
.ok_or_else(stride_overflow)?; .ok_or_else(stride_overflow)?;
verify_checksum(file_data, page_off, elem_at(page_off, page_count)?)?;
for e in 0..page_count { for e in 0..page_count {
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?; push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
} }
@@ -374,6 +406,14 @@ fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> {
mod tests { mod tests {
use super::*; 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] #[test]
fn index_to_offsets_1d() { fn index_to_offsets_1d() {
let num_chunks = vec![5u64]; let num_chunks = vec![5u64];
@@ -439,7 +479,7 @@ mod tests {
buf[8..16].copy_from_slice(&5u64.to_le_bytes()); buf[8..16].copy_from_slice(&5u64.to_le_bytes());
// data_block_address (offset_size=8) // data_block_address (offset_size=8)
buf[16..24].copy_from_slice(&0x1000u64.to_le_bytes()); buf[16..24].copy_from_slice(&0x1000u64.to_le_bytes());
// checksum (4 bytes, we don't validate in parse) stamp_checksum(&mut buf, 0, 24);
let header = FixedArrayHeader::parse(&buf, 0, 8, 8).unwrap(); let header = FixedArrayHeader::parse(&buf, 0, 8, 8).unwrap();
assert_eq!(header.client_id, 1); assert_eq!(header.client_id, 1);
@@ -449,6 +489,54 @@ mod tests {
assert_eq!(header.data_block_address, 0x1000); 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] #[test]
fn parse_fixed_array_header_invalid_signature() { fn parse_fixed_array_header_invalid_signature() {
let mut buf = vec![0u8; 256]; let mut buf = vec![0u8; 256];
@@ -469,6 +557,7 @@ mod tests {
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift 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 + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes()); 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 // FADB so parsing reaches the paged check
let db = 0x100usize; let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB"); buf[db..db + 4].copy_from_slice(b"FADB");
@@ -486,6 +575,8 @@ mod tests {
buf[fahd + 7] = 10; buf[fahd + 7] = 10;
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count 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()); 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"); 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], &[20], 8, 8, 8);
@@ -545,6 +636,7 @@ mod tests {
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes()); file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24] file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes()); .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 // Build FADB at db_offset
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB"); file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
@@ -562,6 +654,7 @@ mod tests {
let pos = elem_start + i * os; let pos = elem_start + i * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes()); 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 = let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap(); FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
@@ -611,6 +704,7 @@ mod tests {
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes()); file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24] file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes()); .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..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0; file_data[db_offset + 4] = 0;
@@ -632,6 +726,11 @@ mod tests {
file_data[pos + os..pos + os + 4].copy_from_slice(&csize.to_le_bytes()); 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()); 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 = let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap(); FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
@@ -696,6 +795,7 @@ mod tests {
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes()); file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24] file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes()); .copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
// FADB prefix // FADB prefix
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB"); file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
@@ -715,6 +815,9 @@ mod tests {
let base_addr = 0x1000u64; let base_addr = 0x1000u64;
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses; // 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. // 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] { for &p in &[0usize, 2usize] {
let page_off = pages_start + p * page_total; let page_off = pages_start + p * page_total;
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts); let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
@@ -724,6 +827,7 @@ mod tests {
let pos = page_off + e * os; let pos = page_off + e * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes()); 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 = let header =
@@ -1318,3 +1318,72 @@ with h5py.File("{sparse_str}", "w", libver="latest") as f:
.count(); .count();
assert_eq!(wrong, 0, "sparse: {wrong} of {n} elements read back wrong"); assert_eq!(wrong, 0, "sparse: {wrong} of {n} elements read back wrong");
} }
#[test]
fn corrupting_a_chunk_index_is_an_error_not_wrong_data() {
// Every Fixed/Extensible Array structure carries a Jenkins checksum, and
// the reader now verifies it. The point is not the checksum itself but
// what it prevents: a damaged index otherwise yields addresses pointing
// at the wrong bytes, and the caller receives another chunk's data as if
// it were the one asked for.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
for (name, maxshape) in [("fixed", "None"), ("extensible", "(None,)")] {
let path = dir.path().join(format!("{name}.h5"));
let path_str = path.display().to_string();
let shape_arg = if maxshape == "None" {
String::new()
} else {
format!(", maxshape={maxshape}")
};
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=(400,), chunks=(1,), dtype="i4"{shape_arg})
d[...] = np.arange(400, dtype="i4")
"#
));
let clean = std::fs::read(&path).unwrap();
assert_eq!(
File::open(&path)
.unwrap()
.dataset("x")
.unwrap()
.read_i32()
.unwrap()
.len(),
400,
"{name}: the intact file must read"
);
// Flip a low bit of a chunk address inside a data block. Structurally
// everything still parses — the index still has the right shape and
// the address still lands inside the file — so nothing but the
// checksum can notice. Without it the read succeeds and hands back
// whatever bytes now sit at that address.
let sig: &[u8] = if name == "fixed" { b"FADB" } else { b"EADB" };
let block = clean
.windows(4)
.position(|w| w == sig)
.unwrap_or_else(|| panic!("{name}: no data block in the fixture"));
// Past the prefix (signature, version, client id, header address, and
// for the Extensible Array a block offset), into the first address.
let at = block + 4 + 1 + 1 + 8 + if name == "fixed" { 0 } else { 4 } + 1;
let mut damaged = clean.clone();
damaged[at] ^= 0x10;
let damaged_path = dir.path().join(format!("{name}_damaged.h5"));
std::fs::write(&damaged_path, &damaged).unwrap();
let result = File::open(&damaged_path)
.unwrap()
.dataset("x")
.and_then(|d| d.read_i32());
assert!(
result.is_err(),
"{name}: corruption produced data instead of an error"
);
}
}