clawhdf5: FileEditor skips optional filters that fail, as libhdf5 does

The editor stored every chunk through the whole pipeline with filter mask 0.
For LZF that did not shrink a chunk, h5py instead stores it raw with the
filter's mask bit set. A chunk the editor stored LZF-encoded at exactly the
raw size was then rewritten raw by libhdf5 at the same size; libhdf5 does not
touch the index entry when the size is unchanged, so the stale mask 0 stayed
and h5py (and h5dump) could no longer read the dataset.

clawhdf5_format::filters::compress_chunk_masked runs the pipeline as
H5Z_pipeline does: an optional filter (H5Z_FLAG_OPTIONAL) that fails is
skipped and its bit set, a mandatory one fails the write, and LZF/Blosc
output no smaller than the input counts as failure, as in the reference
filters (their output buffer is the input's size). Deflate, LZ4, Zstd,
bitshuffle and bzip2 never fail on size in libhdf5 and are kept as before.

Test: edit_interop optional_filters_that_fail_are_skipped — the reviewer's
repro at every libver: the editor stores the chunk exactly as h5py does
(mask 1, size 5; shuffle+LZF+fletcher32 mask 2), h5py r+ rewrites and
extends the datasets, and h5py, h5dump and our reader read every value.
Fails on the previous editor (mask 0; h5dump cannot read /u8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 14:09:34 -05:00
co-authored by Claude Opus 5.5
parent 677dc5ec7c
commit f7e2ab12f2
4 changed files with 260 additions and 5 deletions
+120
View File
@@ -346,6 +346,72 @@ pub fn compress_chunk(
Ok(result)
}
/// Filter flag bit 0: `H5Z_FLAG_OPTIONAL`.
const FILTER_FLAG_OPTIONAL: u16 = 0x0001;
/// Filters whose reference HDF5 filter (h5py's `lzf_filter.c`,
/// hdf5-blosc's `blosc_filter.c`) gives the encoder an output buffer only as
/// large as its input, so output that is not smaller than the input is a
/// failure there.
const FAIL_UNLESS_SMALLER: &[u16] = &[
crate::filter_pipeline::FILTER_LZF,
crate::filter_pipeline::FILTER_BLOSC,
];
/// Run a chunk through a filter pipeline for writing the way libhdf5's
/// `H5Z_pipeline` does, returning the bytes to store and the chunk's filter
/// mask (bit `i` set: filter `i` was skipped).
///
/// A filter that fails is skipped if the pipeline marks it optional
/// (`H5Z_FLAG_OPTIONAL`): its mask bit is set and the next filter gets the
/// same input. A mandatory filter that fails fails the write. Failure
/// includes what the reference filter counts as failure: LZF and Blosc
/// output that is not smaller than the input (h5py then stores the chunk
/// unfiltered with the bit set; storing it filtered with a clear mask can
/// leave a stale mask once libhdf5 rewrites the chunk at the same size).
///
/// A filter this build cannot encode is [`FormatError::UnsupportedFilter`]
/// even when optional: libhdf5 skips an optional filter only when its own
/// build lacks it, and every libhdf5 has the ones clawhdf5 cannot encode.
pub fn compress_chunk_masked(
data: &[u8],
pipeline: &FilterPipeline,
element_size: u32,
) -> Result<(Vec<u8>, u32), FormatError> {
if pipeline.filters.len() > 32 {
return Err(FormatError::CompressionError(
"more than 32 filters in a pipeline".into(),
));
}
let mut result = data.to_vec();
let mut mask = 0u32;
for (i, filter) in pipeline.filters.iter().enumerate() {
let ctx = FilterContext {
filter,
element_size: element_size as usize,
max_output: 0,
};
let out = match filter_registry::encode(&result, &ctx) {
Ok(out)
if FAIL_UNLESS_SMALLER.contains(&filter.filter_id) && out.len() >= result.len() =>
{
Err(FormatError::CompressionError(format!(
"filter {} did not shrink the chunk",
filter.filter_id
)))
}
r => r,
};
match out {
Ok(out) => result = out,
Err(e @ FormatError::UnsupportedFilter(_)) => return Err(e),
Err(_) if filter.flags & FILTER_FLAG_OPTIONAL != 0 => mask |= 1 << i,
Err(e) => return Err(e),
}
}
Ok((result, mask))
}
/// The filters compiled into this build, sorted by ID (see
/// [`crate::filter_registry`]). A filter whose cargo feature is off is left
/// out, so it fails as [`FormatError::UnsupportedFilter`] like any unknown ID.
@@ -2099,6 +2165,60 @@ mod tests {
}
}
/// `compress_chunk_masked` follows `H5Z_pipeline`: an optional LZF that
/// does not shrink the chunk is skipped with its mask bit set (h5py
/// stores `[182, 0, 0, 0, 0]` raw with mask 1), a mandatory one fails,
/// and filters that grow the data (deflate) are kept, as libhdf5 keeps
/// them.
#[test]
#[cfg(all(feature = "lzf", feature = "deflate"))]
fn masked_compression_skips_optional_filters_that_fail() {
use crate::filter_pipeline::FILTER_LZF;
let opt = |id: u16| FilterDescription {
flags: FILTER_FLAG_OPTIONAL,
..filter(id)
};
let pl = |filters: Vec<FilterDescription>| FilterPipeline {
version: 2,
filters,
};
let raw = [182u8, 0, 0, 0, 0];
let (out, mask) = compress_chunk_masked(&raw, &pl(vec![opt(FILTER_LZF)]), 1).unwrap();
assert_eq!((out.as_slice(), mask), (&raw[..], 1));
let (out, mask) = compress_chunk_masked(
&raw,
&pl(vec![
opt(FILTER_SHUFFLE),
opt(FILTER_LZF),
filter(FILTER_FLETCHER32),
]),
1,
)
.unwrap();
assert_eq!((out.len(), mask), (raw.len() + 4, 2));
assert_eq!(
decompress_chunk_masked(
&out,
&pl(vec![
opt(FILTER_SHUFFLE),
opt(FILTER_LZF),
filter(FILTER_FLETCHER32)
]),
raw.len(),
1,
mask
)
.unwrap(),
raw
);
assert!(compress_chunk_masked(&raw, &pl(vec![filter(FILTER_LZF)]), 1).is_err());
let zeros = [0u8; 256];
let (out, mask) = compress_chunk_masked(&zeros, &pl(vec![opt(FILTER_LZF)]), 1).unwrap();
assert!(out.len() < zeros.len() && mask == 0);
let (out, mask) = compress_chunk_masked(&raw, &pl(vec![opt(FILTER_DEFLATE)]), 1).unwrap();
assert!(out.len() > raw.len() && mask == 0);
}
#[test]
#[cfg(feature = "deflate")]
fn filter_mask_skips_only_the_masked_filters() {