fix(format): N-Bit pass-through flag and no-op (enum) members

- libhdf5 sets cd_values[1] ("need not compress") when every field is
  already full width and then stores the chunk unchanged
  (H5Z__filter_nbit: `if (cd_values[1]) HGOTO_DONE`). We ignored it and
  tried to unpack, so tfilters.h5 / h5stat_filters.h5 `/all` (shuffle +
  szip + deflate + fletcher32 + N-Bit) failed with "nbit: packed data too
  short". A type with no N-Bit parameters (cd = [3, 1, nelmts]) is now
  accepted the same way.
- Class 4 (H5Z_NBIT_NOOPTYPE: enum, string, opaque, ... members) is
  stored whole, 8 bits per byte; it was UnsupportedFilter(5)
  (h5repack_nested_8bit_enum_deflated.h5).

N-Bit on floats was not wrong in the filter: for le_data.h5 /
Nbit_float_data_* our output equals libhdf5's decoded bytes in the file
datatype (a 20-bit float, offset 7, bias 31). h5py's values differ
because libhdf5 then converts that custom float layout to IEEE, which
our datatype reader does not do; nbit_float_matches_libhdf5_file_type_bytes
pins the filter output and the doc comment says where conversion belongs.

Tests: nbit_need_not_compress_is_passthrough,
nbit_in_multi_filter_pipeline_matches_libhdf5 (tfilters.h5 chunk, szip
feature), nbit_compound_with_enum_member_matches_libhdf5 all failed
before; nbit_float_matches_libhdf5_file_type_bytes (guard).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-25 21:14:14 -05:00
co-authored by Claude Opus 5.5
parent 5935e13866
commit 3000b40cf3
+187 -6
View File
@@ -385,6 +385,9 @@ enum NbitNode {
count: usize, count: usize,
base_size: usize, base_size: usize,
}, },
/// `H5Z_NBIT_NOOPTYPE`: a field N-Bit does not reduce (enum, string,
/// opaque, ...), stored as all `size` bytes, 8 bits each.
Noop { size: usize },
} }
impl NbitNode { impl NbitNode {
@@ -395,6 +398,7 @@ impl NbitNode {
NbitNode::Array { NbitNode::Array {
count, base_size, .. count, base_size, ..
} => count * base_size, } => count * base_size,
NbitNode::Noop { size } => *size,
} }
} }
} }
@@ -414,6 +418,7 @@ fn parse_nbit_node(cd: &[u32], idx: &mut usize, depth: u32) -> Result<NbitNode,
const ATOMIC: u32 = 1; const ATOMIC: u32 = 1;
const ARRAY: u32 = 2; const ARRAY: u32 = 2;
const COMPOUND: u32 = 3; const COMPOUND: u32 = 3;
const NOOPTYPE: u32 = 4;
if depth > NBIT_MAX_DEPTH { if depth > NBIT_MAX_DEPTH {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"nbit: type tree nested too deeply".into(), "nbit: type tree nested too deeply".into(),
@@ -489,8 +494,17 @@ fn parse_nbit_node(cd: &[u32], idx: &mut usize, depth: u32) -> Result<NbitNode,
members, members,
}) })
} }
// Class 4 is H5Z_NBIT_NOOPTYPE (members copied verbatim) — not seen in NOOPTYPE => {
// practice for the supported leaf types and left unsupported. // class, size
let size = nbit_cd(cd, *idx + 1)? as usize;
*idx += 2;
if size == 0 {
return Err(FormatError::ChunkedReadError(
"nbit: invalid no-op type size".into(),
));
}
Ok(NbitNode::Noop { size })
}
_ => Err(FormatError::UnsupportedFilter(FILTER_NBIT)), _ => Err(FormatError::UnsupportedFilter(FILTER_NBIT)),
} }
} }
@@ -555,6 +569,11 @@ fn decode_nbit_node(
decode_nbit_node(bnode, br, elem, base + i * base_size)?; decode_nbit_node(bnode, br, elem, base + i * base_size)?;
} }
} }
NbitNode::Noop { size } => {
for slot in &mut elem[base..base + size] {
*slot = br.read(8)? as u8;
}
}
} }
Ok(()) Ok(())
} }
@@ -567,17 +586,28 @@ fn decode_nbit_node(
/// type tree — atomic (`[1, size, order, precision, offset]`), array /// type tree — atomic (`[1, size, order, precision, offset]`), array
/// (`[2, total_size, <base>]`) and compound /// (`[2, total_size, <base>]`) and compound
/// (`[3, total_size, nmembers, (offset, <node>)*]`) — preceded by /// (`[3, total_size, nmembers, (offset, <node>)*]`) — preceded by
/// `[nparms, flag, nelmts]`. Decompression walks the tree once per element, /// `[nparms, need_not_compress, nelmts]`; when `need_not_compress` is set
/// (every field already uses its full width, e.g. a 32-bit int of precision
/// 32) libhdf5 stores the data unchanged and so do we. Fields N-Bit cannot
/// reduce (enums, strings, ...) are no-op nodes (`[4, size]`) copied whole.
/// Decompression walks the tree once per element,
/// placing each field's bits at its byte/bit offset in a zero-filled element /// placing each field's bits at its byte/bit offset in a zero-filled element
/// (HDF5's canonical reduced-precision layout). Sign-extension of reduced /// (HDF5's canonical reduced-precision layout). Sign-extension of reduced
/// precision signed integers is the datatype reader's job. Atomic floats are /// precision signed integers is the datatype reader's job, and so is
/// encoded as full-precision atomics and handled transparently. /// converting a reduced-precision float (its own sign/exponent/mantissa
/// layout, e.g. `le_data.h5`'s 20-bit `Nbit_float_data_*`) to IEEE: the
/// filter's output is the file type's bytes, as libhdf5's is before type
/// conversion.
fn nbit_decompress(data: &[u8], cd: &[u32], expected_bytes: usize) -> Result<Vec<u8>, FormatError> { fn nbit_decompress(data: &[u8], cd: &[u32], expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
if cd.len() < 4 { if cd.len() < 3 {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"nbit: missing filter client data".into(), "nbit: missing filter client data".into(),
)); ));
} }
// H5Z__filter_nbit: `if (cd_values[1]) HGOTO_DONE(*buf_size)`.
if cd[1] != 0 {
return Ok(data.to_vec());
}
let nelmts = cd[2] as usize; let nelmts = cd[2] as usize;
let mut idx = 3; let mut idx = 3;
let root = parse_nbit_node(cd, &mut idx, 0)?; let root = parse_nbit_node(cd, &mut idx, 0)?;
@@ -1584,6 +1614,157 @@ mod tests {
// --- LZ4 tests --- // --- LZ4 tests ---
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn one_filter(filter_id: u16, client_data: Vec<u32>) -> FilterDescription {
FilterDescription {
filter_id,
name: None,
flags: 1,
client_data,
}
}
/// `le_data.h5` `/Nbit_float_data_{le,be}` chunk (0,0): a 20-bit float
/// (offset 7) packed by N-Bit. The filter must reproduce libhdf5's
/// decoded bytes in the *file* datatype (h5py `DatasetID.read` with the
/// file type as memory type, so no conversion); converting that custom
/// float layout to IEEE is the datatype reader's job, not the filter's.
#[test]
fn nbit_float_matches_libhdf5_file_type_bytes() {
let file: &[u8] = include_bytes!("../tests/fixtures/filters/le_data.h5");
let cases = [
(
55952,
0,
"8055d5018055e5010000f0018055e5010000f0018055f5010000f0018055f50180aafa018055f50180aafa0100000002",
),
(
56076,
1,
"01d5558001e5558001f0000001e5558001f0000001f5558001f0000001f5558001faaa8001f5558001faaa8002000000",
),
];
for (off, order, want) in cases {
let pipeline = FilterPipeline {
version: 2,
filters: vec![one_filter(FILTER_NBIT, vec![8, 0, 12, 1, 4, order, 20, 7])],
};
let got = decompress_chunk(&file[off..off + 31], &pipeline, 48, 4).unwrap();
assert_eq!(got, unhex(want), "byte order {order}");
}
}
/// libhdf5 sets `cd_values[1]` ("need not compress") when every field is
/// already full width and then stores the data unchanged; we unpacked it
/// anyway and failed with "nbit: packed data too short".
#[test]
fn nbit_need_not_compress_is_passthrough() {
let data: Vec<u8> = (0..200u32).map(|i| (i * 7) as u8).collect();
let pipeline = FilterPipeline {
version: 2,
filters: vec![one_filter(FILTER_NBIT, vec![8, 1, 50, 1, 4, 0, 32, 0])],
};
assert_eq!(decompress_chunk(&data, &pipeline, 200, 4).unwrap(), data);
// A top-level type N-Bit has no parameters for (e.g. an enum) carries only
// [nparms, need_not_compress, nelmts].
let pipeline = FilterPipeline {
version: 2,
filters: vec![one_filter(FILTER_NBIT, vec![3, 1, 50])],
};
assert_eq!(decompress_chunk(&data, &pipeline, 200, 4).unwrap(), data);
}
/// `tfilters.h5` `/all` chunk (0,0): shuffle, szip, deflate, fletcher32
/// and a pass-through N-Bit in one pipeline; values from h5py.
#[test]
#[cfg(feature = "szip")]
fn nbit_in_multi_filter_pipeline_matches_libhdf5() {
let raw = unhex(concat!(
"785e3bc1c0c030cb6517ff039e556de1576c0f300b152c771070641170640b99ba879f8167d5cce82f760ccc4285d71b",
"20c2afc226329f60d60ab5636a3fc1905499c3c0a1d0c4a1d05c6a13d7f88271aaf6725fe7170c86363f1858c0ca0104",
"bf1e95c75f3eeb",
));
let want = unhex(concat!(
"00000000010000000200000003000000040000000a0000000b0000000c0000000d0000000e0000001400000015000000",
"1600000017000000180000001e0000001f00000020000000210000002200000028000000290000002a0000002b000000",
"2c00000032000000330000003400000035000000360000003c0000003d0000003e0000003f0000004000000046000000",
"4700000048000000490000004a00000050000000510000005200000053000000540000005a0000005b0000005c000000",
"5d0000005e000000",
));
let pipeline = FilterPipeline {
version: 2,
filters: vec![
one_filter(FILTER_SHUFFLE, vec![4]),
one_filter(FILTER_SZIP, vec![141, 4, 32, 5]),
one_filter(FILTER_DEFLATE, vec![5]),
one_filter(FILTER_FLETCHER32, vec![]),
one_filter(FILTER_NBIT, vec![8, 1, 50, 1, 4, 0, 32, 0]),
],
};
assert_eq!(decompress_chunk(&raw, &pipeline, 200, 4).unwrap(), want);
}
/// `h5repack_nested_8bit_enum_deflated.h5` `/tracks/1/trace` chunk 0: a
/// 376-byte compound whose `u1` enum member N-Bit stores whole as a
/// no-op type (class 4), then deflate. Was `UnsupportedFilter(5)`.
/// Expected bytes: libhdf5's decode in the file datatype.
#[test]
#[cfg(feature = "deflate")]
fn nbit_compound_with_enum_member_matches_libhdf5() {
#[rustfmt::skip]
let cd: Vec<u32> = vec![
251, 0, 1, 3, 376, 38,
0, 1, 4, 0, 32, 0,
8, 2, 96, 1, 8, 0, 64, 0,
104, 1, 8, 0, 64, 0, 112, 1, 8, 0, 64, 0, 120, 1, 8, 0, 64, 0,
128, 1, 8, 0, 64, 0, 136, 1, 8, 0, 64, 0, 144, 1, 8, 0, 64, 0,
152, 1, 8, 0, 64, 0,
160, 2, 24, 1, 8, 0, 64, 0, 184, 2, 24, 1, 8, 0, 64, 0,
208, 2, 16, 1, 8, 0, 64, 0, 224, 2, 32, 1, 8, 0, 64, 0,
256, 2, 16, 1, 4, 0, 32, 0, 272, 2, 16, 1, 4, 0, 32, 0,
288, 2, 32, 1, 8, 0, 64, 0, 320, 2, 16, 1, 8, 0, 64, 0,
336, 2, 8, 1, 4, 0, 32, 0,
344, 4, 1,
346, 1, 2, 0, 16, 0, 348, 1, 4, 0, 32, 0, 352, 1, 1, 0, 4, 0,
354, 1, 2, 0, 16, 0, 356, 1, 1, 0, 4, 0, 357, 1, 1, 0, 4, 0,
358, 1, 1, 0, 4, 0, 359, 1, 1, 0, 4, 0, 360, 1, 1, 0, 4, 0,
361, 1, 1, 0, 4, 0, 362, 1, 1, 0, 4, 0, 364, 1, 1, 0, 4, 0,
363, 1, 1, 0, 4, 0, 365, 1, 1, 0, 4, 0, 366, 1, 1, 0, 4, 0,
367, 1, 1, 0, 4, 0, 368, 1, 1, 0, 4, 0, 369, 1, 1, 0, 4, 0,
370, 1, 1, 0, 4, 0,
];
assert_eq!(cd.len(), 251);
let raw = unhex(concat!(
"780163606078c930c880c3879573a60b2d7883eeac06a880835c47bda16cda7987a0c59ec9f74c4af6ff677e5df16445",
"adfd8493ce3ba592ddedab2a3bee87dd0faaff00d1808b66606006fa9d999b818125014837fd4703fba1fa71d150e720",
"512caa4073dc59212206500946060600604c37fc",
));
let want = unhex(concat!(
"e90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000eca012979ca9f040000000000000000000000000000000000000000000000080cf661d317f881e40",
"7434de6349a352407da8e478eb03ffbf47631ab943c9903f52df56df88797a3f000000000000f07f000000000000f07f",
"000000000000f07f000000000000f07fe90300000b0300006004000082030000ffffffffffffffffffffffffffffffff",
"000000000000f0bf000000000000f0bf000000000000f0bf000000000000f0bf00000000000000000000000000000000",
"25040000470300000500000000000000030000000000000000000000000000000100000000000000",
));
let pipeline = FilterPipeline {
version: 2,
filters: vec![
one_filter(FILTER_NBIT, cd),
one_filter(FILTER_DEFLATE, vec![1]),
],
};
assert_eq!(decompress_chunk(&raw, &pipeline, 376, 376).unwrap(), want);
}
/// Chunk (0,0) of `/DS1` in the HDF Group's `h5ex_d_lz4.h5` example, /// Chunk (0,0) of `/DS1` in the HDF Group's `h5ex_d_lz4.h5` example,
/// written by libhdf5's registered LZ4 plugin with a 3-byte block size /// written by libhdf5's registered LZ4 plugin with a 3-byte block size
/// (so it has many blocks, some stored raw). Byte range from h5py's /// (so it has many blocks, some stored raw). Byte range from h5py's