fix(format): a chunk that decodes short is an error, not zero-filled
HDF5 stores every chunk at the full chunk size (edge chunks are padded before filtering, and with "don't filter partial edge chunks" they are stored raw at full size), so a filter pipeline that decodes to fewer bytes means a corrupt chunk. Every chunk reader padded it with zeros and returned it as data. libhdf5 returns the rest uninitialised, or fails when the filter checks (Blosc with nbytes = 0). New filters::decompress_chunk_exact decodes and then requires exactly the chunk size, with the chunk's coordinates in the error (ChunkedReadError "chunk at [16] decoded to 16 bytes, expected 32"). It replaces decompress_chunk_masked at every chunk read path: the full read (sequential and lane-partitioned), the cached read, the sweep read, the planned-selection read, parallel_read's three decoders and partial_read's box read. decompress_chunk_masked is unchanged (fractal-heap huge objects already checked their own size). Blosc also rejects a frame declaring no data where the chunk size is known. Tests, each failing with the check disabled: filters and parallel_read unit tests; h5py_short_decoded_chunk_is_an_error (gzip chunks rewritten short with write_direct_chunk: 1-D, a 2-D edge chunk, and 40 chunks with shuffle, read through File full/cached/selection reads, a selection that avoids the chunk still reads, MmapFile and LazyFile, with and without the parallel feature); plugin_filters_interop short_decoding_chunks_are_errors (Blosc nbytes=0 and short, LZF and bzip2 short; the Blosc nbytes=0 case read as 16 zeros before). The existing don't-filter-partial-edge-chunks tests still pass. Conformance (tank, 2026-09-26): 573 of 697 ok, and no file changed class, reader result or first issue against the pre-fix run. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
use crate::chunked_read::ChunkInfo;
|
||||
use crate::error::FormatError;
|
||||
use crate::filter_pipeline::FilterPipeline;
|
||||
use crate::filters::decompress_chunk_masked;
|
||||
use crate::filters::decompress_chunk_exact;
|
||||
use crate::lane_partition::{self, LaneStats, PartitionStats};
|
||||
|
||||
/// Threshold: only use parallel decompression when chunk count exceeds this.
|
||||
@@ -84,12 +84,13 @@ pub fn decompress_chunks_lane_partitioned(
|
||||
}
|
||||
let raw_chunk = &file_data[c_addr..c_addr + size];
|
||||
|
||||
let decompressed = decompress_chunk_masked(
|
||||
let decompressed = decompress_chunk_exact(
|
||||
raw_chunk,
|
||||
pipeline,
|
||||
chunk_total_bytes,
|
||||
element_size,
|
||||
chunk_info.filter_mask,
|
||||
&chunk_info.offsets,
|
||||
)?;
|
||||
|
||||
stats.chunks_processed += 1;
|
||||
@@ -160,12 +161,13 @@ pub fn decompress_chunks_parallel(
|
||||
}
|
||||
let raw_chunk = &file_data[c_addr..c_addr + size];
|
||||
|
||||
let decompressed = decompress_chunk_masked(
|
||||
let decompressed = decompress_chunk_exact(
|
||||
raw_chunk,
|
||||
pipeline,
|
||||
chunk_total_bytes,
|
||||
element_size,
|
||||
chunk_info.filter_mask,
|
||||
&chunk_info.offsets,
|
||||
)?;
|
||||
|
||||
Ok(DecompressedChunk {
|
||||
@@ -204,12 +206,13 @@ pub fn decompress_chunks_sequential(
|
||||
let raw_chunk = &file_data[c_addr..c_addr + size];
|
||||
|
||||
let decompressed = if let Some(pl) = pipeline {
|
||||
decompress_chunk_masked(
|
||||
decompress_chunk_exact(
|
||||
raw_chunk,
|
||||
pl,
|
||||
chunk_total_bytes,
|
||||
element_size,
|
||||
chunk_info.filter_mask,
|
||||
&chunk_info.offsets,
|
||||
)?
|
||||
} else {
|
||||
raw_chunk.to_vec()
|
||||
@@ -218,3 +221,60 @@ pub fn decompress_chunks_sequential(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::filter_pipeline::{FILTER_SHUFFLE, FilterDescription};
|
||||
|
||||
/// Eight shuffled 32-byte chunks; chunk 5 is stored short when `short`.
|
||||
fn chunks(short: bool) -> (Vec<u8>, Vec<ChunkInfo>) {
|
||||
let mut file = Vec::new();
|
||||
let mut infos = Vec::new();
|
||||
for i in 0..8u64 {
|
||||
let len = if short && i == 5 { 16 } else { 32 };
|
||||
infos.push(ChunkInfo {
|
||||
chunk_size: len as u32,
|
||||
filter_mask: 0,
|
||||
offsets: vec![i * 8],
|
||||
address: file.len() as u64,
|
||||
});
|
||||
file.extend(core::iter::repeat_n(i as u8, len));
|
||||
}
|
||||
(file, infos)
|
||||
}
|
||||
|
||||
/// Every parallel decoder refuses a chunk that decodes short, naming it.
|
||||
#[test]
|
||||
fn short_decoded_chunk_is_an_error() {
|
||||
let pipeline = FilterPipeline {
|
||||
version: 2,
|
||||
filters: vec![FilterDescription {
|
||||
filter_id: FILTER_SHUFFLE,
|
||||
name: None,
|
||||
flags: 0,
|
||||
client_data: vec![4],
|
||||
}],
|
||||
};
|
||||
let (file, good) = chunks(false);
|
||||
assert_eq!(
|
||||
decompress_chunks_parallel(&file, &good, &pipeline, 32, 4).unwrap()[5],
|
||||
[5u8; 32]
|
||||
);
|
||||
let (file, bad) = chunks(true);
|
||||
let errs = [
|
||||
decompress_chunks_lane_partitioned(&file, &bad, &pipeline, 32, 4, 1, Some(3))
|
||||
.map(|_| ())
|
||||
.unwrap_err(),
|
||||
decompress_chunks_parallel(&file, &bad, &pipeline, 32, 4)
|
||||
.map(|_| ())
|
||||
.unwrap_err(),
|
||||
decompress_chunks_sequential(&file, &bad, Some(&pipeline), 32, 4)
|
||||
.map(|_| ())
|
||||
.unwrap_err(),
|
||||
];
|
||||
for e in errs {
|
||||
assert!(e.to_string().contains("[40]"), "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user