Skip to main content

rust_hdf5/format/messages/
virtual_mapping.rs

1//! Virtual Dataset mapping list — the global-heap-resident payload a
2//! Virtual layout message
3//! ([`crate::format::messages::data_layout::DataLayoutMessage::Virtual`])
4//! points at by heap address and object index
5//! (`H5D__virtual_load_layout`, H5Dvirtual.c). Unlike the External File
6//! List message's slot names (which index into a *local* heap that needs
7//! a second address-based read), everything here — including both source
8//! and destination names — is inline in the one heap object this module
9//! decodes: no further indirection.
10//!
11//! Binary layout (the heap object's raw bytes, in full):
12//! ```text
13//! heap_version: 1 byte (0 or 1)
14//! num_entries:  sizeof_size bytes LE
15//! num_entries * entry {
16//!     if heap_version >= 1: flags: 1 byte
17//!         (bit0 = SOURCE_FILE_SHARED, bit1 = SOURCE_DSET_SHARED,
18//!          bit2 = SOURCE_SAME_FILE — heap_version 0 has no flags byte at
19//!          all, so every name is always an inline string)
20//!
21//!     source file name:
22//!       if flags & SAME_FILE: no bytes on the wire; name is "."
23//!       elif flags & FILE_SHARED: origin_index (sizeof_size bytes LE),
24//!           must be < this entry's index; reuse that entry's name
25//!       else: NUL-terminated string
26//!
27//!     source dataset name: same three forms, using
28//!       DSET_SHARED/origin_index instead of FILE_SHARED (no "same file"
29//!       form — a dataset is never "the VDS itself")
30//!
31//!     source_selection:  a serialized H5S selection (see
32//!       [`crate::format::selection::Selection::decode`])
33//!     virtual_selection:  a serialized H5S selection, immediately after
34//! }
35//! checksum: 4 bytes LE (Jenkins lookup3 / H5_checksum_metadata over
36//!           every byte before it)
37//! ```
38//!
39//! Empirically confirmed byte-for-byte against a real h5py-written VDS
40//! (`heap_version` 0 in every fixture h5py's `create_virtual_dataset`
41//! produces — h5py never emits the version-1 name-sharing optimizations,
42//! though a spec-conformant reader still has to decode them).
43
44use crate::format::checksum::checksum_metadata;
45use crate::format::selection::Selection;
46use crate::format::{FormatContext, FormatError, FormatResult};
47
48const ENC_VERS_0: u8 = 0;
49const ENC_VERS_1: u8 = 1;
50
51const SOURCE_FILE_SHARED: u8 = 0x01;
52const SOURCE_DSET_SHARED: u8 = 0x02;
53const SOURCE_SAME_FILE: u8 = 0x04;
54const ALL_FLAGS: u8 = SOURCE_FILE_SHARED | SOURCE_DSET_SHARED | SOURCE_SAME_FILE;
55
56/// One (source, virtual) mapping entry.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct VirtualMapping {
59    /// The source file's name, exactly as stored (or `"."` for
60    /// `SOURCE_SAME_FILE`) — resolved against `HDF5_VDS_PREFIX` /
61    /// the VDS file's own directory at read time, not here.
62    pub source_file_name: String,
63    /// The source dataset's path within that file.
64    pub source_dset_name: String,
65    /// Which elements of the source dataset this mapping reads.
66    pub source_selection: Selection,
67    /// Which elements of the virtual dataset this mapping fills.
68    pub virtual_selection: Selection,
69}
70
71/// A source name split around its `printf`-style block substitutions —
72/// `H5D_virtual_parse_source_name` (H5Dvirtual.c).
73///
74/// A virtual dataset whose virtual selection is unlimited and whose source
75/// selection is not draws each block of the virtual selection from a
76/// *different* source dataset, named by substituting the block index into
77/// the stored name. Only two conversions are legal: `%b`, the block index,
78/// and `%%`, an escaped literal `%`. Anything else is "invalid format
79/// specifier", and libhdf5 raises it both when the mapping is set and when
80/// the layout is loaded back out of the file, so a name that does not parse
81/// makes the dataset unopenable rather than merely unwritable.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ParsedSourceName {
84    /// The literal text around the substitutions, `%%` already unescaped to
85    /// a single `%` — upstream's `H5O_storage_virtual_name_seg_t` chain.
86    /// Always exactly one longer than the substitution count, so joining the
87    /// segments with the printed block index is the whole of
88    /// `H5D__virtual_build_source_name`.
89    segments: Vec<String>,
90}
91
92impl ParsedSourceName {
93    /// How many `%b` substitutions the name carries — upstream's `nsubs`,
94    /// the quantity `H5D_virtual_check_mapping_post` tests to decide whether
95    /// a mapping is a printf mapping at all.
96    pub fn nsubs(&self) -> usize {
97        self.segments.len() - 1
98    }
99
100    /// The name block `blockno` resolves to —
101    /// `H5D__virtual_build_source_name`. With no substitutions this is the
102    /// unescaped name, which is what upstream uses for an ordinary mapping
103    /// too (`H5D__virtual_load_layout` takes `parsed_name->name_segment`,
104    /// not the stored string, whenever the name parsed into one).
105    pub fn build(&self, blockno: u64) -> String {
106        self.segments.join(&blockno.to_string())
107    }
108}
109
110/// Split a source file or dataset name around its `%b` substitutions —
111/// `H5D_virtual_parse_source_name` (H5Dvirtual.c). See [`ParsedSourceName`].
112pub fn parse_source_name(name: &str) -> FormatResult<ParsedSourceName> {
113    let mut segments = vec![String::new()];
114    let mut rest = name;
115    while let Some(pct) = rest.find('%') {
116        let (literal, tail) = rest.split_at(pct);
117        segments.last_mut().expect("never empty").push_str(literal);
118        match tail.as_bytes().get(1) {
119            Some(b'b') => segments.push(String::new()),
120            Some(b'%') => segments.last_mut().expect("never empty").push('%'),
121            _ => {
122                return Err(FormatError::InvalidData(format!(
123                    "invalid format specifier in virtual dataset source name {name:?}: only \
124                     %b (block index) and %% (escaped percent) are legal"
125                )))
126            }
127        }
128        rest = &tail[2.min(tail.len())..];
129    }
130    segments.last_mut().expect("never empty").push_str(rest);
131    Ok(ParsedSourceName { segments })
132}
133
134/// A decoded Virtual Dataset mapping list.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct VirtualMappingList {
137    pub mappings: Vec<VirtualMapping>,
138}
139
140impl VirtualMappingList {
141    /// Encode this mapping list into the bytes one global heap object holds
142    /// — `H5D__virtual_store_layout` (H5Dvirtual.c).
143    ///
144    /// Always at heap encoding version 0, because that is the only version
145    /// libhdf5 writes short of `H5F_LIBVER_V200`: version 1 exists solely to
146    /// shorten repeated names (`SOURCE_SAME_FILE` / the two `_SHARED` forms),
147    /// and `H5D__virtual_store_layout` picks it only when the file's *low*
148    /// bound is at least V200 *and* it measures the version-1 block as no
149    /// larger. Nothing here is lost by staying at 0: the same mappings decode
150    /// back identically, one inline name per entry.
151    ///
152    /// A name holding an interior NUL is refused rather than written: the
153    /// wire form terminates each name with one, so a name containing another
154    /// would read back truncated — and everything after it in the block would
155    /// decode as some other field.
156    pub fn encode(&self, ctx: &FormatContext) -> FormatResult<Vec<u8>> {
157        let ss = ctx.sizeof_size as usize;
158        let mut buf = Vec::new();
159        buf.push(ENC_VERS_0);
160        buf.extend_from_slice(&(self.mappings.len() as u64).to_le_bytes()[..ss]);
161        for m in &self.mappings {
162            push_cstr(&mut buf, &m.source_file_name, "source file")?;
163            push_cstr(&mut buf, &m.source_dset_name, "source dataset")?;
164            buf.extend_from_slice(&m.source_selection.encode()?);
165            buf.extend_from_slice(&m.virtual_selection.encode()?);
166        }
167        let cksum = checksum_metadata(&buf);
168        buf.extend_from_slice(&cksum.to_le_bytes());
169        Ok(buf)
170    }
171
172    /// Decode a mapping list from a global heap object's raw bytes.
173    ///
174    /// Unlike most message decoders here, this does not return a
175    /// "bytes consumed" count: `buf` is expected to be exactly one heap
176    /// object's data (as `GlobalHeapCollection::get_object` returns it),
177    /// and every byte in it belongs to this structure — trailing bytes
178    /// left over after the checksum are a corrupt block, not data for a
179    /// caller to continue decoding, so that case is an error instead.
180    pub fn decode(buf: &[u8], ctx: &FormatContext) -> FormatResult<Self> {
181        let ss = ctx.sizeof_size as usize;
182        if buf.is_empty() {
183            return Err(FormatError::BufferTooShort {
184                needed: 1,
185                available: 0,
186            });
187        }
188        let heap_version = buf[0];
189        if heap_version != ENC_VERS_0 && heap_version != ENC_VERS_1 {
190            return Err(FormatError::InvalidVersion(heap_version));
191        }
192        let mut pos = 1;
193
194        if buf.len() < pos + ss {
195            return Err(FormatError::BufferTooShort {
196                needed: pos + ss,
197                available: buf.len(),
198            });
199        }
200        let num_entries = crate::format::bytes::read_le_uint(&buf[pos..], ss) as usize;
201        pos += ss;
202
203        let mut mappings: Vec<VirtualMapping> = Vec::new();
204        for i in 0..num_entries {
205            let flags = if heap_version >= ENC_VERS_1 {
206                if buf.len() < pos + 1 {
207                    return Err(FormatError::BufferTooShort {
208                        needed: pos + 1,
209                        available: buf.len(),
210                    });
211                }
212                let f = buf[pos];
213                pos += 1;
214                if f & !ALL_FLAGS != 0 {
215                    return Err(FormatError::InvalidData(format!(
216                        "unknown virtual dataset mapping flag bits in {f:#x}"
217                    )));
218                }
219                f
220            } else {
221                0
222            };
223
224            let source_file_name = if flags & SOURCE_SAME_FILE != 0 {
225                ".".to_string()
226            } else if flags & SOURCE_FILE_SHARED != 0 {
227                let origin = read_origin_index(buf, &mut pos, ss, i)?;
228                mappings[origin].source_file_name.clone()
229            } else {
230                read_cstr(buf, &mut pos)?
231            };
232
233            let source_dset_name = if flags & SOURCE_DSET_SHARED != 0 {
234                let origin = read_origin_index(buf, &mut pos, ss, i)?;
235                mappings[origin].source_dset_name.clone()
236            } else {
237                read_cstr(buf, &mut pos)?
238            };
239
240            let (source_selection, consumed) = Selection::decode(&buf[pos..])?;
241            pos += consumed;
242            let (virtual_selection, consumed) = Selection::decode(&buf[pos..])?;
243            pos += consumed;
244
245            // `H5D__virtual_load_layout` parses both names as it decodes the
246            // entry, so a name with an illegal conversion fails the load
247            // rather than surfacing later as a source that cannot be found.
248            parse_source_name(&source_file_name)?;
249            parse_source_name(&source_dset_name)?;
250
251            mappings.push(VirtualMapping {
252                source_file_name,
253                source_dset_name,
254                source_selection,
255                virtual_selection,
256            });
257        }
258
259        if buf.len() < pos + 4 {
260            return Err(FormatError::BufferTooShort {
261                needed: pos + 4,
262                available: buf.len(),
263            });
264        }
265        let stored_cksum = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
266        let computed_cksum = checksum_metadata(&buf[..pos]);
267        if stored_cksum != computed_cksum {
268            return Err(FormatError::ChecksumMismatch {
269                expected: stored_cksum,
270                computed: computed_cksum,
271            });
272        }
273        pos += 4;
274
275        if pos != buf.len() {
276            return Err(FormatError::InvalidData(format!(
277                "virtual dataset mapping list declares {pos} bytes but the heap object holds {}",
278                buf.len()
279            )));
280        }
281
282        Ok(Self { mappings })
283    }
284}
285
286/// Append a name and its NUL terminator, refusing one that already holds a
287/// NUL (see [`VirtualMappingList::encode`]).
288fn push_cstr(buf: &mut Vec<u8>, name: &str, what: &str) -> FormatResult<()> {
289    if name.as_bytes().contains(&0) {
290        return Err(FormatError::InvalidData(format!(
291            "virtual dataset {what} name {name:?} contains a NUL, which terminates a \
292             name on the wire"
293        )));
294    }
295    buf.extend_from_slice(name.as_bytes());
296    buf.push(0);
297    Ok(())
298}
299
300/// Read a `sizeof_size`-byte origin-entry index and validate it points
301/// strictly before the current entry (`H5D__virtual_load_layout`'s own
302/// check — a forward or self reference is malformed, not just unusual).
303fn read_origin_index(
304    buf: &[u8],
305    pos: &mut usize,
306    ss: usize,
307    this_entry: usize,
308) -> FormatResult<usize> {
309    if buf.len() < *pos + ss {
310        return Err(FormatError::BufferTooShort {
311            needed: *pos + ss,
312            available: buf.len(),
313        });
314    }
315    let origin = crate::format::bytes::read_le_uint(&buf[*pos..], ss) as usize;
316    *pos += ss;
317    if origin >= this_entry {
318        return Err(FormatError::InvalidData(format!(
319            "virtual dataset mapping entry {this_entry} shares a name with entry {origin}, \
320             which is not an earlier entry"
321        )));
322    }
323    Ok(origin)
324}
325
326/// Read a NUL-terminated string starting at `*pos`, requiring the
327/// terminator to appear within `buf` (an unterminated string is a
328/// truncated/corrupt block, matching `H5D__virtual_load_layout`'s own
329/// "ran off end of input buffer... unterminated" check).
330fn read_cstr(buf: &[u8], pos: &mut usize) -> FormatResult<String> {
331    let start = *pos;
332    let nul = buf[start..].iter().position(|&b| b == 0).ok_or_else(|| {
333        FormatError::InvalidData(
334            "virtual dataset mapping entry has an unterminated name string".into(),
335        )
336    })?;
337    let s = String::from_utf8_lossy(&buf[start..start + nul]).into_owned();
338    *pos = start + nul + 1;
339    Ok(s)
340}
341
342// ======================================================================= tests
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::format::selection::{Hyperslab, HyperslabBlock};
348
349    fn ctx8() -> FormatContext {
350        FormatContext {
351            sizeof_addr: 8,
352            sizeof_size: 8,
353        }
354    }
355
356    fn all_selection_bytes() -> Vec<u8> {
357        let mut b = vec![0x03, 0, 0, 0]; // SEL_ALL
358        b.extend_from_slice(&1u32.to_le_bytes()); // version 1
359        b.extend_from_slice(&[0u8; 8]); // reserved
360        b
361    }
362
363    /// A single-entry, both-sides-ALL heap block, built to exactly match
364    /// what h5debug reported for a real h5py-written VDS
365    /// (`layout[...] = VirtualSource(...)`): heap_version 0 (no flags
366    /// byte), inline names, checksum computed over the real body.
367    fn single_entry_all_block() -> Vec<u8> {
368        let mut body = vec![ENC_VERS_0];
369        body.extend_from_slice(&1u64.to_le_bytes()); // num_entries
370        body.extend_from_slice(b"src.h5\0");
371        body.extend_from_slice(b"data\0");
372        body.extend_from_slice(&all_selection_bytes()); // source selection
373        body.extend_from_slice(&all_selection_bytes()); // virtual selection
374        let cksum = checksum_metadata(&body);
375        body.extend_from_slice(&cksum.to_le_bytes());
376        body
377    }
378
379    #[test]
380    fn decode_single_all_mapping() {
381        let buf = single_entry_all_block();
382        let list = VirtualMappingList::decode(&buf, &ctx8()).unwrap();
383        assert_eq!(list.mappings.len(), 1);
384        let m = &list.mappings[0];
385        assert_eq!(m.source_file_name, "src.h5");
386        assert_eq!(m.source_dset_name, "data");
387        assert_eq!(m.source_selection, Selection::All);
388        assert_eq!(m.virtual_selection, Selection::All);
389    }
390
391    #[test]
392    fn decode_empty_mapping_list() {
393        let mut body = vec![ENC_VERS_0];
394        body.extend_from_slice(&0u64.to_le_bytes());
395        let cksum = checksum_metadata(&body);
396        body.extend_from_slice(&cksum.to_le_bytes());
397        let list = VirtualMappingList::decode(&body, &ctx8()).unwrap();
398        assert!(list.mappings.is_empty());
399    }
400
401    #[test]
402    fn decode_rejects_bad_checksum() {
403        let mut buf = single_entry_all_block();
404        let last = buf.len() - 1;
405        buf[last] ^= 0xFF;
406        let err = VirtualMappingList::decode(&buf, &ctx8()).unwrap_err();
407        assert!(matches!(err, FormatError::ChecksumMismatch { .. }));
408    }
409
410    #[test]
411    fn decode_rejects_bad_heap_version() {
412        let mut buf = single_entry_all_block();
413        buf[0] = 2;
414        let err = VirtualMappingList::decode(&buf, &ctx8()).unwrap_err();
415        assert!(matches!(err, FormatError::InvalidVersion(2)));
416    }
417
418    #[test]
419    fn decode_rejects_unterminated_name() {
420        let mut body = vec![ENC_VERS_0];
421        body.extend_from_slice(&1u64.to_le_bytes());
422        body.extend_from_slice(b"no_nul_here"); // never terminated
423        let err = VirtualMappingList::decode(&body, &ctx8()).unwrap_err();
424        assert!(matches!(err, FormatError::InvalidData(_)));
425    }
426
427    /// heap_version 1's `SOURCE_SAME_FILE` flag emits no bytes for the
428    /// source file name at all — it always means the literal `"."` — and
429    /// two entries can each independently set it.
430    #[test]
431    fn decode_heap_version_1_same_file() {
432        let mut body = vec![ENC_VERS_1];
433        body.extend_from_slice(&2u64.to_le_bytes()); // num_entries
434
435        // Entry 0: SAME_FILE.
436        body.push(SOURCE_SAME_FILE);
437        body.extend_from_slice(b"a\0");
438        body.extend_from_slice(&all_selection_bytes());
439        body.extend_from_slice(&all_selection_bytes());
440
441        // Entry 1: also SAME_FILE, different dataset.
442        body.push(SOURCE_SAME_FILE);
443        body.extend_from_slice(b"b\0");
444        body.extend_from_slice(&all_selection_bytes());
445        body.extend_from_slice(&all_selection_bytes());
446
447        let cksum = checksum_metadata(&body);
448        body.extend_from_slice(&cksum.to_le_bytes());
449
450        let list = VirtualMappingList::decode(&body, &ctx8()).unwrap();
451        assert_eq!(list.mappings.len(), 2);
452        assert_eq!(list.mappings[0].source_file_name, ".");
453        assert_eq!(list.mappings[1].source_file_name, ".");
454        assert_eq!(list.mappings[0].source_dset_name, "a");
455        assert_eq!(list.mappings[1].source_dset_name, "b");
456    }
457
458    /// heap_version 1's `SOURCE_FILE_SHARED`/`SOURCE_DSET_SHARED` flags
459    /// reference an earlier entry's already-decoded name by index.
460    #[test]
461    fn decode_heap_version_1_shared_names() {
462        let mut body = vec![ENC_VERS_1];
463        body.extend_from_slice(&2u64.to_le_bytes());
464
465        // Entry 0: literal names.
466        body.push(0);
467        body.extend_from_slice(b"shared.h5\0");
468        body.extend_from_slice(b"data\0");
469        body.extend_from_slice(&all_selection_bytes());
470        body.extend_from_slice(&all_selection_bytes());
471
472        // Entry 1: both names shared from entry 0.
473        body.push(SOURCE_FILE_SHARED | SOURCE_DSET_SHARED);
474        body.extend_from_slice(&0u64.to_le_bytes()); // origin for file
475        body.extend_from_slice(&0u64.to_le_bytes()); // origin for dset
476        body.extend_from_slice(&all_selection_bytes());
477        body.extend_from_slice(&all_selection_bytes());
478
479        let cksum = checksum_metadata(&body);
480        body.extend_from_slice(&cksum.to_le_bytes());
481
482        let list = VirtualMappingList::decode(&body, &ctx8()).unwrap();
483        assert_eq!(list.mappings[1].source_file_name, "shared.h5");
484        assert_eq!(list.mappings[1].source_dset_name, "data");
485    }
486
487    /// A shared-name origin index that is not strictly earlier than the
488    /// current entry (self or forward reference) is malformed, matching
489    /// `H5D__virtual_load_layout`'s own check.
490    #[test]
491    fn decode_rejects_non_earlier_shared_origin() {
492        let mut body = vec![ENC_VERS_1];
493        body.extend_from_slice(&1u64.to_le_bytes());
494        body.push(SOURCE_FILE_SHARED);
495        body.extend_from_slice(&0u64.to_le_bytes()); // origin == this entry's own index (0)
496        let err = VirtualMappingList::decode(&body, &ctx8()).unwrap_err();
497        assert!(matches!(err, FormatError::InvalidData(_)));
498    }
499
500    #[test]
501    fn decode_rejects_unknown_flag_bits() {
502        let mut body = vec![ENC_VERS_1];
503        body.extend_from_slice(&1u64.to_le_bytes());
504        body.push(0x08); // no such flag bit
505        let err = VirtualMappingList::decode(&body, &ctx8()).unwrap_err();
506        assert!(matches!(err, FormatError::InvalidData(_)));
507    }
508
509    /// A mapping with a real (non-ALL) hyperslab selection round-trips
510    /// through the same block, matching `layout[4:12] = ...`.
511    #[test]
512    fn decode_mapping_with_hyperslab_virtual_selection() {
513        let mut hyper = vec![0x02, 0, 0, 0]; // SEL_HYPERSLABS
514        hyper.extend_from_slice(&1u32.to_le_bytes()); // version 1
515        hyper.extend_from_slice(&[0u8; 8]);
516        hyper.extend_from_slice(&1u32.to_le_bytes()); // rank
517        hyper.extend_from_slice(&1u32.to_le_bytes()); // num_blocks
518        hyper.extend_from_slice(&4u32.to_le_bytes()); // start
519        hyper.extend_from_slice(&11u32.to_le_bytes()); // end
520
521        let mut body = vec![ENC_VERS_0];
522        body.extend_from_slice(&1u64.to_le_bytes());
523        body.extend_from_slice(b"src.h5\0");
524        body.extend_from_slice(b"data\0");
525        body.extend_from_slice(&all_selection_bytes()); // source: ALL
526        body.extend_from_slice(&hyper); // virtual: [4:12]
527        let cksum = checksum_metadata(&body);
528        body.extend_from_slice(&cksum.to_le_bytes());
529
530        let list = VirtualMappingList::decode(&body, &ctx8()).unwrap();
531        match &list.mappings[0].virtual_selection {
532            Selection::Hyperslab {
533                rank: 1,
534                form: Hyperslab::Blocks(blocks),
535            } => {
536                assert_eq!(
537                    blocks,
538                    &vec![HyperslabBlock {
539                        start: vec![4],
540                        end: vec![11],
541                    }]
542                );
543            }
544            other => panic!("expected a rank-1 hyperslab, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn decode_truncated_num_entries() {
550        let buf = [ENC_VERS_0, 0, 0, 0];
551        let err = VirtualMappingList::decode(&buf, &ctx8()).unwrap_err();
552        assert!(matches!(err, FormatError::BufferTooShort { .. }));
553    }
554
555    #[test]
556    fn decode_empty_buffer() {
557        let err = VirtualMappingList::decode(&[], &ctx8()).unwrap_err();
558        assert!(matches!(err, FormatError::BufferTooShort { .. }));
559    }
560
561    /// The 60 bytes libhdf5 1.14 actually wrote for the oracle's `vds` case
562    /// (`h5py.VirtualLayout(shape=(16,))[...] = VirtualSource("vds_src.h5",
563    /// "src", shape=(16,))`), lifted out of the global heap object the layout
564    /// message points at — heap version 0, one entry, both selections ALL.
565    /// `encode` must reproduce it byte for byte, checksum included.
566    #[test]
567    fn encode_matches_the_captured_libhdf5_block() {
568        let list = VirtualMappingList {
569            mappings: vec![VirtualMapping {
570                source_file_name: "vds_src.h5".into(),
571                source_dset_name: "src".into(),
572                source_selection: Selection::All,
573                virtual_selection: Selection::All,
574            }],
575        };
576        let captured = [
577            0x00, // heap encoding version 0
578            0x01, 0, 0, 0, 0, 0, 0, 0, // num_entries = 1
579            b'v', b'd', b's', b'_', b's', b'r', b'c', b'.', b'h', b'5', 0x00, b's', b'r', b'c',
580            0x00, //
581            0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source: ALL
582            0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // virtual: ALL
583            0xcd, 0xe5, 0xe5, 0xed, // checksum
584        ];
585        assert_eq!(list.encode(&ctx8()).unwrap(), captured);
586    }
587
588    #[test]
589    fn encode_roundtrips_a_hyperslab_mapping_at_ctx4() {
590        let ctx4 = FormatContext {
591            sizeof_addr: 4,
592            sizeof_size: 4,
593        };
594        let list = VirtualMappingList {
595            mappings: vec![
596                VirtualMapping {
597                    source_file_name: "a.h5".into(),
598                    source_dset_name: "one".into(),
599                    source_selection: Selection::All,
600                    virtual_selection: Selection::Hyperslab {
601                        rank: 1,
602                        form: Hyperslab::Blocks(vec![HyperslabBlock {
603                            start: vec![0],
604                            end: vec![7],
605                        }]),
606                    },
607                },
608                VirtualMapping {
609                    source_file_name: "b.h5".into(),
610                    source_dset_name: "two".into(),
611                    source_selection: Selection::All,
612                    virtual_selection: Selection::Hyperslab {
613                        rank: 1,
614                        form: Hyperslab::Blocks(vec![HyperslabBlock {
615                            start: vec![8],
616                            end: vec![15],
617                        }]),
618                    },
619                },
620            ],
621        };
622        let encoded = list.encode(&ctx4).unwrap();
623        assert_eq!(VirtualMappingList::decode(&encoded, &ctx4).unwrap(), list);
624    }
625
626    #[test]
627    fn encode_empty_list_roundtrips() {
628        let list = VirtualMappingList {
629            mappings: Vec::new(),
630        };
631        let encoded = list.encode(&ctx8()).unwrap();
632        assert_eq!(encoded.len(), 1 + 8 + 4);
633        assert_eq!(VirtualMappingList::decode(&encoded, &ctx8()).unwrap(), list);
634    }
635
636    #[test]
637    fn encode_rejects_a_name_holding_a_nul() {
638        let list = VirtualMappingList {
639            mappings: vec![VirtualMapping {
640                source_file_name: "sr\0c.h5".into(),
641                source_dset_name: "src".into(),
642                source_selection: Selection::All,
643                virtual_selection: Selection::All,
644            }],
645        };
646        let err = list.encode(&ctx8()).unwrap_err();
647        assert!(matches!(err, FormatError::InvalidData(_)), "{err:?}");
648    }
649
650    #[test]
651    fn decode_rejects_trailing_garbage() {
652        let mut buf = single_entry_all_block();
653        buf.push(0xAB);
654        let err = VirtualMappingList::decode(&buf, &ctx8()).unwrap_err();
655        assert!(matches!(err, FormatError::InvalidData(_)));
656    }
657    /// `H5D_virtual_parse_source_name`: `%b` splits the name, `%%` is an
658    /// escaped literal, and anything else after a `%` is an error. The build
659    /// side is `H5D__virtual_build_source_name`.
660    #[test]
661    fn source_names_parse_and_build_the_way_libhdf5_does() {
662        for (name, nsubs, block7) in [
663            ("plain.h5", 0, "plain.h5"),
664            ("f%b.h5", 1, "f7.h5"),
665            ("%b", 1, "7"),
666            ("a%b%bc", 2, "a77c"),
667            // `%%` is a literal percent and no substitution at all, so the
668            // name a mapping resolves against is the unescaped one.
669            ("od%%d", 0, "od%d"),
670            ("%%%b%%", 1, "%7%"),
671        ] {
672            let parsed = parse_source_name(name).unwrap();
673            assert_eq!(parsed.nsubs(), nsubs, "{name}");
674            assert_eq!(parsed.build(7), block7, "{name}");
675        }
676        // Two-digit block numbers are printed in full, once per specifier.
677        assert_eq!(parse_source_name("b%b_%b").unwrap().build(123), "b123_123");
678        for bad in ["%z", "50%", "%d.h5", "%"] {
679            let err = parse_source_name(bad).unwrap_err();
680            assert!(
681                matches!(&err, FormatError::InvalidData(m) if m.contains("invalid format specifier")),
682                "{bad}: {err:?}"
683            );
684        }
685    }
686
687    /// `H5D__virtual_load_layout` parses both names while decoding, so a
688    /// stored name with an illegal conversion makes the layout unreadable
689    /// rather than surfacing later as a source that cannot be found.
690    #[test]
691    fn decode_rejects_an_illegal_format_specifier_in_a_stored_name() {
692        let list = VirtualMappingList {
693            mappings: vec![VirtualMapping {
694                source_file_name: "src.h5".into(),
695                source_dset_name: "d".into(),
696                source_selection: Selection::All,
697                virtual_selection: Selection::All,
698            }],
699        };
700        let mut buf = list.encode(&ctx8()).unwrap();
701        // Rewrite "src.h5" as "s%z.h5" in place (same length), then fix the
702        // trailing checksum so only the name is what decode objects to.
703        let at = buf
704            .windows(6)
705            .position(|w| w == b"src.h5")
706            .expect("name is inline");
707        buf[at..at + 6].copy_from_slice(b"s%z.h5");
708        let end = buf.len() - 4;
709        let cksum = checksum_metadata(&buf[..end]);
710        buf[end..].copy_from_slice(&cksum.to_le_bytes());
711        let err = VirtualMappingList::decode(&buf, &ctx8()).unwrap_err();
712        assert!(
713            matches!(&err, FormatError::InvalidData(m) if m.contains("invalid format specifier")),
714            "{err:?}"
715        );
716    }
717}