Skip to main content

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}