hdf5_pure/layout_info.rs
1//! Public, curated introspection of a dataset's on-disk storage layout and
2//! filter pipeline (issue #149).
3//!
4//! These types are decoded from the HDF5 data-layout and filter-pipeline
5//! messages but deliberately omit on-disk encoding artifacts (message and layout
6//! version numbers, chunk-index root addresses, and the single-chunk
7//! filtered-size sidecar fields), so the public surface is not welded to the
8//! internal parse representation. Obtain them from the [`Dataset`] accessors
9//! [`layout`], [`chunk_index`], [`chunks`], and [`filter_pipeline`].
10//!
11//! [`Dataset`]: crate::Dataset
12//! [`layout`]: crate::Dataset::layout
13//! [`chunk_index`]: crate::Dataset::chunk_index
14//! [`chunks`]: crate::Dataset::chunks
15//! [`filter_pipeline`]: crate::Dataset::filter_pipeline
16
17#[cfg(not(feature = "std"))]
18use alloc::{format, string::String, vec::Vec};
19
20use core::fmt;
21
22use crate::display::{Dims, EscapedName};
23use crate::error::FormatError;
24use crate::filter_pipeline::{
25 FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
26};
27
28/// How a dataset's raw data is arranged on disk.
29///
30/// The curated analogue of HDF5's layout class (`H5Pget_layout`), enriched with
31/// the per-class facts needed to locate or size the data without decoding it.
32/// Obtain it with [`Dataset::layout`](crate::Dataset::layout).
33///
34/// Use it to choose a reading strategy: a [`Contiguous`](Layout::Contiguous)
35/// dataset is a single seek-and-read, while a [`Chunked`](Layout::Chunked)
36/// dataset is read (and, for an appendable index, grown) one chunk at a time —
37/// enumerate its chunks with [`Dataset::chunks`](crate::Dataset::chunks).
38#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum Layout {
41 /// Stored inline in the dataset's object header, as used for tiny datasets.
42 /// The bytes are already resident once the header is read, so there is no
43 /// separate file region to seek to; `size` is the inline byte count.
44 Compact {
45 /// The number of raw bytes stored inline.
46 size: u64,
47 },
48 /// Stored as one contiguous run of bytes.
49 Contiguous {
50 /// Absolute file offset of the first byte, or `None` when storage has
51 /// not been allocated yet (a fixed-shape dataset that was never
52 /// written). In that case `size` is the extent that *would* be written.
53 address: Option<u64>,
54 /// The length of the run in bytes.
55 size: u64,
56 },
57 /// Stored as a grid of independently located (and optionally filtered)
58 /// chunks. Filtered datasets are always chunked.
59 Chunked {
60 /// The chunk edge lengths, one per dataset dimension, in the same order
61 /// as [`shape`](crate::Dataset::shape). This is the value returned by
62 /// [`chunk_shape`](crate::Dataset::chunk_shape); the on-disk
63 /// element-size dimension is stripped.
64 chunk_shape: Vec<u64>,
65 /// The index that maps chunk coordinates to file addresses, which
66 /// governs append eligibility (see [`ChunkIndex`]).
67 index: ChunkIndex,
68 },
69 /// A virtual dataset whose data is mapped from other datasets. Only the
70 /// classification is exposed; the source mappings are not decoded.
71 Virtual,
72}
73
74/// The kind of index a chunked dataset uses to locate its chunks.
75///
76/// The curated, named form of HDF5's chunk-index type. The index kind is fixed
77/// at dataset creation by the shape and its extensibility, and it determines
78/// whether the dataset can be grown in place: see
79/// [`supports_inplace_append`](ChunkIndex::supports_inplace_append).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81#[non_exhaustive]
82pub enum ChunkIndex {
83 /// A version-1 B-tree indexes the chunks — the classic layout, used for any
84 /// rank and any number of unlimited dimensions in older files.
85 BTreeV1,
86 /// A single chunk holds the entire dataset; there is no separate index
87 /// structure.
88 SingleChunk,
89 /// Chunk addresses are computed arithmetically from each chunk's position
90 /// (a fixed dataspace with every chunk allocated); there is no separate
91 /// index structure.
92 Implicit,
93 /// A fixed array indexes a fixed number of chunks (a non-extensible
94 /// dataspace with more than one chunk).
95 FixedArray,
96 /// An extensible array indexes chunks along a single unlimited dimension.
97 /// This is the index [`Dataset::append`](crate::Dataset::append) and
98 /// [`Dataset::append_staged`](crate::Dataset::append_staged) grow
99 /// in place.
100 ExtensibleArray,
101 /// A version-2 B-tree indexes the chunks (several unlimited dimensions). A
102 /// dataset with this index is classified here, but enumerating its chunks
103 /// with [`Dataset::chunks`](crate::Dataset::chunks) is not yet supported.
104 BTreeV2,
105}
106
107impl ChunkIndex {
108 /// Whether a dataset with this index kind can be grown in place with
109 /// [`Dataset::append`](crate::Dataset::append) — true only for
110 /// [`ExtensibleArray`](ChunkIndex::ExtensibleArray).
111 ///
112 /// This reflects the index *structure* alone; an actual append also requires
113 /// the dataset's first maximum dimension to be unlimited (see
114 /// [`Dataset::maxshape`](crate::Dataset::maxshape)).
115 #[must_use]
116 pub const fn supports_inplace_append(self) -> bool {
117 matches!(self, ChunkIndex::ExtensibleArray)
118 }
119
120 /// Map an internal `(layout version, chunk index type)` pair to a public
121 /// index kind. Version-3 layouts always use a version-1 B-tree; version-4
122 /// layouts carry an explicit index type (1..=5).
123 pub(crate) fn from_layout(version: u8, index_type: Option<u8>) -> Result<Self, FormatError> {
124 Ok(match (version, index_type) {
125 (3, _) => ChunkIndex::BTreeV1,
126 (4, Some(1)) => ChunkIndex::SingleChunk,
127 (4, Some(2)) => ChunkIndex::Implicit,
128 (4, Some(3)) => ChunkIndex::FixedArray,
129 (4, Some(4)) => ChunkIndex::ExtensibleArray,
130 (4, Some(5)) => ChunkIndex::BTreeV2,
131 (v, Some(idx)) => {
132 return Err(FormatError::ChunkedReadError(format!(
133 "unrecognized chunk index (layout version={v}, index type={idx})"
134 )));
135 }
136 (v, None) => {
137 return Err(FormatError::ChunkedReadError(format!(
138 "unrecognized chunk index (layout version={v}, no index type)"
139 )));
140 }
141 })
142 }
143}
144
145/// The location and on-disk footprint of one stored chunk.
146///
147/// A `Chunk` is a lightweight record: enumerating chunks reads only the chunk
148/// index, never the chunk data. To read one chunk, seek to
149/// [`address`](Self::address), read exactly [`storage_size`](Self::storage_size)
150/// bytes, then invert the dataset's
151/// [`filter_pipeline`](crate::Dataset::filter_pipeline) in *reverse* order
152/// (skipping the filters marked in [`filter_mask`](Self::filter_mask)). The
153/// curated analogue of `H5Dget_chunk_info`; obtain these from
154/// [`Dataset::chunks`](crate::Dataset::chunks).
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[non_exhaustive]
157pub struct Chunk {
158 /// The logical offset of this chunk's first element within the dataset, one
159 /// coordinate per dataset dimension (row-major, in elements). The origin
160 /// chunk is all zeros.
161 pub offset: Vec<u64>,
162 /// The absolute file offset of this chunk's stored bytes.
163 pub address: u64,
164 /// The number of bytes stored at [`address`](Self::address): the filtered
165 /// (compressed) size for a filtered dataset, or the raw chunk byte size
166 /// otherwise.
167 pub storage_size: u64,
168 /// Per-filter skip mask: if bit *i* is set, the *i*-th filter of the
169 /// pipeline was not applied to this chunk. `0` means every filter applies.
170 pub filter_mask: u32,
171}
172
173/// One filter in a dataset's pipeline.
174///
175/// The curated per-filter analogue of `H5Pget_filter2`. Obtain the ordered
176/// pipeline with [`Dataset::filter_pipeline`](crate::Dataset::filter_pipeline);
177/// [`Dataset::filters`](crate::Dataset::filters) stays the lighter call when
178/// only the identifiers are needed.
179#[derive(Debug, Clone, PartialEq, Eq)]
180#[non_exhaustive]
181pub struct Filter {
182 /// The registered HDF5 filter identifier, the same numbering returned by
183 /// [`Dataset::filters`](crate::Dataset::filters). `Display` names the ones
184 /// this crate knows, such as 1 = deflate or 32000 = lzf.
185 pub id: u16,
186 /// The filter's recorded name, when the file stores one. Absent for most
187 /// built-in filters, which are identified by [`id`](Self::id) alone.
188 pub name: Option<String>,
189 /// Whether the filter is optional. When `true`, a reader that cannot apply
190 /// the filter may skip it; a mandatory filter (`false`) must be applied for
191 /// the data to decode correctly.
192 pub is_optional: bool,
193 /// The filter's client data (`cd_values`): the auxiliary parameters stored
194 /// with it — for deflate, one value, the compression level. The meaning is
195 /// filter-specific.
196 pub client_data: Vec<u32>,
197}
198
199// ---- Display ----
200//
201// A caller prints these to describe a dataset, so `Display` is the one-line
202// form. `Debug` keeps the full record.
203
204impl fmt::Display for Layout {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 match self {
207 Self::Compact { size } => write!(f, "compact ({size} bytes)"),
208 Self::Contiguous {
209 address: Some(address),
210 size,
211 } => write!(f, "contiguous ({size} bytes at 0x{address:x})"),
212 Self::Contiguous {
213 address: None,
214 size,
215 } => write!(f, "contiguous ({size} bytes, unallocated)"),
216 Self::Chunked { chunk_shape, index } => {
217 write!(f, "chunked ({}, {index} index)", Dims(chunk_shape))
218 }
219 Self::Virtual => f.write_str("virtual"),
220 }
221 }
222}
223
224impl fmt::Display for ChunkIndex {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 f.pad(match self {
227 Self::BTreeV1 => "B-tree v1",
228 Self::SingleChunk => "single chunk",
229 Self::Implicit => "implicit",
230 Self::FixedArray => "fixed array",
231 Self::ExtensibleArray => "extensible array",
232 Self::BTreeV2 => "B-tree v2",
233 })
234 }
235}
236
237impl fmt::Display for Filter {
238 /// The filter's name and its client data, as `deflate(6)`. A filter this
239 /// crate does not name carries its identifier in the same parentheses —
240 /// `custom(id=40000)` — so the parentheses hold the filter's parameters and
241 /// nothing else.
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 let well_known = well_known_filter_name(self.id);
244 match well_known {
245 Some(name) => f.write_str(name)?,
246 // Recorded by the file, so escaped for the same reason a compound's
247 // member name is.
248 None => write!(
249 f,
250 "{}",
251 EscapedName(self.name.as_deref().unwrap_or("filter"))
252 )?,
253 }
254
255 // An unnamed identifier is a parameter of its own, and comes first.
256 let write_id = well_known.is_none();
257 if write_id || !self.client_data.is_empty() {
258 f.write_str("(")?;
259 if write_id {
260 write!(f, "id={}", self.id)?;
261 }
262 for (i, value) in self.client_data.iter().enumerate() {
263 if write_id || i > 0 {
264 f.write_str(", ")?;
265 }
266 write!(f, "{value}")?;
267 }
268 f.write_str(")")?;
269 }
270
271 if self.is_optional {
272 f.write_str(" [optional]")?;
273 }
274 Ok(())
275 }
276}
277
278/// The name of a filter this crate knows by identifier.
279///
280/// Most built-in filters record no name of their own, which would otherwise
281/// leave a bare number in the output. Naming one is not a claim that this crate
282/// can run it: a message reporting a filter it cannot decode is exactly where
283/// the name earns its keep.
284fn well_known_filter_name(id: u16) -> Option<&'static str> {
285 Some(match id {
286 // The filters this crate implements are matched through their
287 // constants, so the two lists cannot drift apart.
288 FILTER_DEFLATE => "deflate",
289 FILTER_SHUFFLE => "shuffle",
290 FILTER_FLETCHER32 => "fletcher32",
291 FILTER_SCALEOFFSET => "scaleoffset",
292 FILTER_LZF => "lzf",
293 // Registered identifiers with no constant here: szip and nbit have no
294 // implementation, and `FILTER_ZFP` is behind the `zfp` feature while
295 // the name is worth reporting whether or not the decoder is built.
296 4 => "szip",
297 5 => "nbit",
298 32013 => "zfp",
299 _ => return None,
300 })
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn chunk_index_from_layout_maps_every_kind() {
309 assert_eq!(
310 ChunkIndex::from_layout(3, None).unwrap(),
311 ChunkIndex::BTreeV1
312 );
313 assert_eq!(
314 ChunkIndex::from_layout(3, Some(4)).unwrap(),
315 ChunkIndex::BTreeV1,
316 "v3 is always a v1 B-tree regardless of the index-type byte"
317 );
318 assert_eq!(
319 ChunkIndex::from_layout(4, Some(1)).unwrap(),
320 ChunkIndex::SingleChunk
321 );
322 assert_eq!(
323 ChunkIndex::from_layout(4, Some(2)).unwrap(),
324 ChunkIndex::Implicit
325 );
326 assert_eq!(
327 ChunkIndex::from_layout(4, Some(3)).unwrap(),
328 ChunkIndex::FixedArray
329 );
330 assert_eq!(
331 ChunkIndex::from_layout(4, Some(4)).unwrap(),
332 ChunkIndex::ExtensibleArray
333 );
334 assert_eq!(
335 ChunkIndex::from_layout(4, Some(5)).unwrap(),
336 ChunkIndex::BTreeV2
337 );
338 }
339
340 #[test]
341 fn chunk_index_from_layout_rejects_unknown() {
342 assert!(ChunkIndex::from_layout(4, Some(9)).is_err());
343 assert!(ChunkIndex::from_layout(4, None).is_err());
344 assert!(ChunkIndex::from_layout(2, Some(1)).is_err());
345 }
346
347 #[test]
348 fn only_extensible_array_supports_inplace_append() {
349 assert!(ChunkIndex::ExtensibleArray.supports_inplace_append());
350 for idx in [
351 ChunkIndex::BTreeV1,
352 ChunkIndex::SingleChunk,
353 ChunkIndex::Implicit,
354 ChunkIndex::FixedArray,
355 ChunkIndex::BTreeV2,
356 ] {
357 assert!(!idx.supports_inplace_append());
358 }
359 }
360}
361
362#[cfg(all(test, feature = "std"))]
363mod display_tests {
364 use super::*;
365
366 #[test]
367 fn a_layout_reads_as_one_line() {
368 assert_eq!(
369 Layout::Compact { size: 40 }.to_string(),
370 "compact (40 bytes)"
371 );
372 assert_eq!(
373 Layout::Contiguous {
374 address: Some(0x2a0),
375 size: 128,
376 }
377 .to_string(),
378 "contiguous (128 bytes at 0x2a0)"
379 );
380 assert_eq!(
381 Layout::Chunked {
382 chunk_shape: vec![4, 8],
383 index: ChunkIndex::ExtensibleArray,
384 }
385 .to_string(),
386 "chunked (4x8, extensible array index)"
387 );
388 }
389
390 /// An unallocated dataset says so, rather than printing `None`.
391 #[test]
392 fn an_unallocated_contiguous_dataset_says_so() {
393 let layout = Layout::Contiguous {
394 address: None,
395 size: 64,
396 };
397 let shown = layout.to_string();
398 assert_eq!(shown, "contiguous (64 bytes, unallocated)");
399 assert!(!shown.contains("None"));
400 }
401
402 /// Most built-in filters record no name, which would leave a bare number.
403 #[test]
404 fn a_filter_is_named_by_its_identifier_when_the_file_records_none() {
405 let deflate = Filter {
406 id: 1,
407 name: None,
408 is_optional: false,
409 client_data: vec![6],
410 };
411 assert_eq!(deflate.to_string(), "deflate(6)");
412
413 let lzf = Filter {
414 id: 32000,
415 name: None,
416 is_optional: false,
417 client_data: vec![],
418 };
419 assert_eq!(lzf.to_string(), "lzf");
420 }
421
422 #[test]
423 fn an_unregistered_filter_falls_back_to_its_recorded_name_then_its_id() {
424 let named = Filter {
425 id: 40000,
426 name: Some("custom".into()),
427 is_optional: true,
428 client_data: vec![],
429 };
430 assert_eq!(named.to_string(), "custom(id=40000) [optional]");
431
432 let anonymous = Filter {
433 id: 40001,
434 name: None,
435 is_optional: false,
436 client_data: vec![],
437 };
438 assert_eq!(anonymous.to_string(), "filter(id=40001)");
439 }
440
441 /// The file records this name, so it cannot reach a message unescaped.
442 #[test]
443 fn a_recorded_filter_name_cannot_carry_a_control_character() {
444 let hostile = Filter {
445 id: 40000,
446 name: Some("evil\u{1b}[31m\nname".into()),
447 is_optional: false,
448 client_data: vec![],
449 };
450 let shown = hostile.to_string();
451 assert!(!shown.chars().any(char::is_control), "{shown}");
452 assert_eq!(shown, "evil\\u{1b}[31m\\nname(id=40000)");
453 }
454
455 /// The `zfp` identifier is written as a literal, its constant being behind
456 /// a feature, so it is pinned to that constant here.
457 #[cfg(feature = "zfp")]
458 #[test]
459 fn the_zfp_name_is_reached_through_its_own_identifier() {
460 assert_eq!(
461 well_known_filter_name(crate::filter_pipeline::FILTER_ZFP),
462 Some("zfp")
463 );
464 }
465
466 /// The identifier is labeled, so it cannot read as one of the client-data
467 /// values it sits beside.
468 #[test]
469 fn an_unregistered_filter_keeps_its_id_apart_from_its_client_data() {
470 let named = Filter {
471 id: 40000,
472 name: Some("custom".into()),
473 is_optional: false,
474 client_data: vec![7, 8],
475 };
476 assert_eq!(named.to_string(), "custom(id=40000, 7, 8)");
477 }
478
479 /// The message reports the index-type byte itself, not the `Option` that
480 /// carries it.
481 #[test]
482 fn an_unrecognized_index_error_has_no_rust_option_in_it() {
483 let with_type = ChunkIndex::from_layout(4, Some(9)).unwrap_err().to_string();
484 assert!(with_type.contains("index type=9"), "{with_type}");
485 assert!(!with_type.contains("Some"), "{with_type}");
486
487 let without_type = ChunkIndex::from_layout(9, None).unwrap_err().to_string();
488 assert!(without_type.contains("no index type"), "{without_type}");
489 assert!(!without_type.contains("None"), "{without_type}");
490 }
491}