Skip to main content

rust_hdf5/format/
dense_attr.rs

1//! Dense attribute storage.
2//!
3//! Once an object accumulates more attributes than the object header's
4//! `max_compact` threshold — or one attribute too large to encode as a header
5//! message — libhdf5 moves *all* of its attributes out of the object header
6//! (`H5Oattribute.c::H5O__attr_create`). The `Attribute Info` message then
7//! points at a fractal heap holding each attribute as an encoded attribute
8//! message, plus a v2 B-tree indexing them by name hash
9//! (`H5Adense.c::H5A__dense_create`).
10//!
11//! The name index is the authority on which attributes exist: the heap alone
12//! cannot be enumerated reliably (an attribute at or above the heap's
13//! `max_man_size` is a "huge" object living outside the managed blocks). So
14//! this module walks the B-tree, pulls each record's heap ID, and resolves it
15//! through the heap.
16//!
17//! Reference: `H5Adense.c` (`H5A__dense_iterate`), `H5Abtree2.c`
18//! (`H5A__dense_btree2_name_encode`).
19
20use crate::format::checksum::checksum_metadata;
21use crate::format::chunk_index::btree_v2::{
22    build_index as build_btree_v2_index, collect_btree_v2_records, Bt2Header, BT2_TYPE_ATTR_CORDER,
23    BT2_TYPE_ATTR_NAME,
24};
25use crate::format::creation_order::CreationOrder;
26use crate::format::fractal_heap::{
27    collect_managed_blocks, read_heap_object, FractalHeapHeader, HeapId, HeapParams,
28};
29use crate::format::fractal_heap_write::{build_heap, HeapBlock};
30use crate::format::messages::attr_info::{
31    next_creation_index, AttributeInfoMessage, MAX_CREATION_ORDER_INDEX,
32};
33use crate::format::messages::attribute::AttributeEntry;
34use crate::format::messages::MSG_FLAG_SHARED;
35use crate::format::{BlockReader, FormatContext, FormatError, FormatResult, UNDEF_ADDR};
36
37/// Length of the fractal-heap ID embedded in a dense-attribute name record
38/// (`H5O_FHEAP_ID_LEN`).
39const FHEAP_ID_LEN: usize = 8;
40
41/// A name-index record: heap ID, message flags, creation order, name hash.
42/// 17 bytes on disk (`H5A__dense_btree2_name_encode`).
43const NAME_RECORD_LEN: usize = FHEAP_ID_LEN + 1 + 4 + 4;
44
45/// A creation-order-index record: heap ID, message flags, creation order.
46/// 13 bytes on disk (`H5A__dense_btree2_corder_encode`).
47const CORDER_RECORD_LEN: usize = FHEAP_ID_LEN + 1 + 4;
48
49/// Node size of either index (`H5A_NAME_BT2_NODE_SIZE`,
50/// `H5A_CORDER_BT2_NODE_SIZE`).
51const NAME_BT2_NODE_SIZE: u32 = 512;
52
53/// The hash a name is indexed under (`H5A__dense_insert`).
54pub fn name_hash(name: &str) -> u32 {
55    checksum_metadata(name.as_bytes())
56}
57
58/// Read every attribute an object keeps in dense storage.
59///
60/// Returns them in name-index (hash) order, the order `H5Aiterate2` walks with
61/// `H5_INDEX_NAME`, each carrying the creation index its name record records
62/// (`H5A__dense_btree2_name_decode`). Hash order is *not* creation order, so a
63/// caller that needs the latter has the value to sort on rather than the
64/// position it arrived in. An `ainfo` describing compact storage yields an
65/// empty vector; a record the reader cannot resolve to a heap object is an
66/// error, not a silent omission, so a partially-read dense object never
67/// masquerades as a complete one. A heap object that resolves but whose payload
68/// this crate cannot model is named rather than dropped — see
69/// [`AttributeEntry::parse`].
70pub fn read_dense_attributes<R: BlockReader>(
71    ainfo: &AttributeInfoMessage,
72    ctx: &FormatContext,
73    reader: &mut R,
74) -> FormatResult<Vec<AttributeEntry>> {
75    if !ainfo.is_dense() {
76        return Ok(Vec::new());
77    }
78    if ainfo.name_btree_address == UNDEF_ADDR {
79        return Err(FormatError::InvalidData(
80            "dense attribute storage without a name index B-tree".into(),
81        ));
82    }
83
84    // The heap header's on-disk size depends only on the address/length
85    // widths, so a generous prefix read covers it.
86    let heap_buf = reader.read_block(ainfo.fractal_heap_address, 512)?;
87    let heap = FractalHeapHeader::decode(&heap_buf, ctx)?;
88    let blocks = collect_managed_blocks(&heap, ctx, reader)?;
89
90    let bt2_buf = reader.read_block(ainfo.name_btree_address, 256)?;
91    let bt2 = Bt2Header::decode(&bt2_buf, ctx)?;
92    if bt2.record_type != BT2_TYPE_ATTR_NAME {
93        return Err(FormatError::InvalidData(format!(
94            "attribute name index has B-tree record type {}, expected {}",
95            bt2.record_type, BT2_TYPE_ATTR_NAME
96        )));
97    }
98    if (bt2.record_size as usize) < NAME_RECORD_LEN {
99        return Err(FormatError::InvalidData(format!(
100            "attribute name index record is {} bytes, expected at least {}",
101            bt2.record_size, NAME_RECORD_LEN
102        )));
103    }
104
105    let records = collect_btree_v2_records(&bt2, ctx, reader)?;
106    let rec_size = bt2.record_size as usize;
107    let mut attrs = Vec::with_capacity(records.len() / rec_size);
108    for rec in records.chunks_exact(rec_size) {
109        // A shared attribute message lives in the file's shared-message heap
110        // rather than this object's; decoding its heap ID against this heap
111        // would read unrelated bytes.
112        if rec[FHEAP_ID_LEN] & MSG_FLAG_SHARED != 0 {
113            return Err(FormatError::UnsupportedFeature(
114                "shared (SOHM) dense attribute".into(),
115            ));
116        }
117        let id = HeapId::parse(&rec[..FHEAP_ID_LEN], &heap, ctx)?;
118        let bytes = read_heap_object(&id, &heap, ctx, &blocks, reader)?;
119        let corder = u32::from_le_bytes([
120            rec[FHEAP_ID_LEN + 1],
121            rec[FHEAP_ID_LEN + 2],
122            rec[FHEAP_ID_LEN + 3],
123            rec[FHEAP_ID_LEN + 4],
124        ]);
125        attrs.push(AttributeEntry::parse(&bytes, ctx)?.with_creation_index(decoded_corder(corder)));
126    }
127    Ok(attrs)
128}
129
130/// The creation index a record's corder field names, or `None` when it holds
131/// the "no creation index" sentinel `H5O_MAX_CRT_ORDER_IDX` that libhdf5
132/// stores for an object which does not track creation order.
133fn decoded_corder(corder: u32) -> Option<u16> {
134    u16::try_from(corder)
135        .ok()
136        .filter(|&c| c != MAX_CREATION_ORDER_INDEX)
137}
138
139/// Dense storage laid out for an object: what its header must say, and what
140/// must be written for that to be true.
141#[derive(Debug, Clone, PartialEq)]
142pub struct DenseAttributeStorage {
143    /// The `Attribute Info` message naming the heap and the name index.
144    pub ainfo: AttributeInfoMessage,
145    /// Heap header, heap blocks, huge objects and both indices' nodes.
146    pub blocks: Vec<HeapBlock>,
147}
148
149/// Lay `attrs` out as dense storage: a fractal heap holding one encoded
150/// attribute message each, plus a v2 B-tree indexing them by name hash.
151///
152/// `alloc` allocates file space and returns the address; every allocation it
153/// hands out is reported back through [`DenseAttributeStorage::blocks`], so a
154/// caller that abandons the result can free exactly what it took.
155///
156/// `order` is the object's attribute creation-order policy: `Tracked` stamps
157/// each record with its real creation index and puts the running maximum in
158/// the `Attribute Info` message, and `Indexed` additionally bulk-loads the
159/// creation-order B-tree.
160///
161/// Mirrors `H5A__dense_create` followed by one `H5A__dense_insert` per
162/// attribute, except that the whole set is known up front, so the index is
163/// bulk-loaded rather than grown by insertion.
164pub fn build_dense_attributes(
165    attrs: &[AttributeEntry],
166    ctx: &FormatContext,
167    order: CreationOrder,
168    alloc: &mut dyn FnMut(u64) -> u64,
169) -> FormatResult<DenseAttributeStorage> {
170    let objects: Vec<Vec<u8>> = attrs.iter().map(|a| a.encode(ctx)).collect();
171    let heap = build_heap(&HeapParams::object_header(), ctx, &objects, alloc)?;
172
173    // `H5A__dense_btree2_name_compare` orders on the hash and breaks ties by
174    // strcmp of the name pulled back out of the heap, so a bulk load has to
175    // sort the same way or a lookup walking the tree misses records.
176    let mut by_name: Vec<usize> = (0..attrs.len()).collect();
177    by_name.sort_by(|&a, &b| {
178        name_hash(attrs[a].name())
179            .cmp(&name_hash(attrs[b].name()))
180            .then_with(|| attrs[a].name().cmp(attrs[b].name()))
181    });
182
183    // Each attribute states its own creation index; nothing here derives one
184    // from a position. Without tracking — or for an attribute that carries no
185    // index because the object it came from tracked nothing — every record
186    // gets the "no creation index" sentinel `H5O_MAX_CRT_ORDER_IDX` the
187    // library writes in that case.
188    let corder = |i: usize| -> u32 {
189        match (order.is_tracked(), attrs[i].creation_index()) {
190            (true, Some(idx)) => u32::from(idx),
191            _ => u32::from(MAX_CREATION_ORDER_INDEX),
192        }
193    };
194
195    let mut records = Vec::with_capacity(by_name.len() * NAME_RECORD_LEN);
196    for &i in &by_name {
197        records.extend_from_slice(&heap.ids[i]);
198        // Nothing here is a shared (SOHM) message.
199        records.push(0);
200        records.extend_from_slice(&corder(i).to_le_bytes());
201        records.extend_from_slice(&name_hash(attrs[i].name()).to_le_bytes());
202    }
203
204    let mut blocks = heap.blocks;
205    let bt2_addr = build_index(
206        BT2_TYPE_ATTR_NAME,
207        NAME_RECORD_LEN as u16,
208        &records,
209        ctx,
210        alloc,
211        &mut blocks,
212    );
213
214    // The creation-order index, when it is asked for. Sorted on the key it is
215    // indexed by (`H5A__dense_btree2_corder_compare`), which is the creation
216    // index itself — a bulk load in any other order is a tree a lookup walks
217    // straight past.
218    let corder_bt2_addr = order.is_indexed().then(|| {
219        let mut by_corder: Vec<usize> = (0..attrs.len()).collect();
220        by_corder.sort_by_key(|&i| corder(i));
221        let mut records = Vec::with_capacity(attrs.len() * CORDER_RECORD_LEN);
222        for &i in &by_corder {
223            records.extend_from_slice(&heap.ids[i]);
224            records.push(0);
225            records.extend_from_slice(&corder(i).to_le_bytes());
226        }
227        build_index(
228            BT2_TYPE_ATTR_CORDER,
229            CORDER_RECORD_LEN as u16,
230            &records,
231            ctx,
232            alloc,
233            &mut blocks,
234        )
235    });
236
237    Ok(DenseAttributeStorage {
238        ainfo: AttributeInfoMessage {
239            // `H5O__attr_create` post-increments `ainfo->max_crt_idx`, so it
240            // is one past the largest index in use — not the attribute count,
241            // which is the same number only while no index has ever been
242            // skipped.
243            max_creation_index: order.is_tracked().then(|| next_creation_index(attrs)),
244            fractal_heap_address: heap.header_addr,
245            name_btree_address: bt2_addr,
246            creation_order_btree_address: corder_bt2_addr,
247        },
248        blocks,
249    })
250}
251
252/// Bulk-load one v2 B-tree, allocate its header and nodes, and append their
253/// images to `blocks`. Returns the header address.
254fn build_index(
255    record_type: u8,
256    record_size: u16,
257    records: &[u8],
258    ctx: &FormatContext,
259    alloc: &mut dyn FnMut(u64) -> u64,
260    blocks: &mut Vec<HeapBlock>,
261) -> u64 {
262    let (bt2_addr, nodes) = build_btree_v2_index(
263        record_type,
264        record_size,
265        NAME_BT2_NODE_SIZE,
266        records,
267        ctx,
268        alloc,
269    );
270    blocks.extend(nodes.into_iter().map(|(addr, image)| HeapBlock {
271        addr,
272        len: image.len() as u64,
273        image,
274    }));
275    bt2_addr
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::format::messages::attribute::AttributeMessage;
282    use crate::format::messages::datatype::DatatypeMessage;
283
284    struct SliceReader<'a>(&'a [u8]);
285
286    impl BlockReader for SliceReader<'_> {
287        fn read_block(&mut self, offset: u64, len: usize) -> FormatResult<Vec<u8>> {
288            let start = offset as usize;
289            if start > self.0.len() {
290                return Err(FormatError::BufferTooShort {
291                    needed: start,
292                    available: self.0.len(),
293                });
294            }
295            let end = (start + len).min(self.0.len());
296            Ok(self.0[start..end].to_vec())
297        }
298    }
299
300    fn ctx() -> FormatContext {
301        FormatContext {
302            sizeof_addr: 8,
303            sizeof_size: 8,
304        }
305    }
306
307    #[test]
308    fn compact_ainfo_reads_no_dense_attributes() {
309        let ainfo = AttributeInfoMessage::compact();
310        let mut reader = SliceReader(&[]);
311        assert!(read_dense_attributes(&ainfo, &ctx(), &mut reader)
312            .unwrap()
313            .is_empty());
314    }
315
316    /// A file image the builder's blocks are written into, so the dense
317    /// reader can be pointed straight back at what the writer produced.
318    struct MemFile {
319        bytes: Vec<u8>,
320    }
321
322    impl MemFile {
323        fn new() -> Self {
324            // Leave the first block unused so address 0 never means "unset".
325            Self { bytes: vec![0; 16] }
326        }
327        fn alloc(&mut self, len: u64) -> u64 {
328            let addr = self.bytes.len() as u64;
329            self.bytes.resize(self.bytes.len() + len as usize, 0);
330            addr
331        }
332    }
333
334    impl BlockReader for MemFile {
335        fn read_block(&mut self, offset: u64, len: usize) -> FormatResult<Vec<u8>> {
336            let start = offset as usize;
337            if start > self.bytes.len() {
338                return Err(FormatError::BufferTooShort {
339                    needed: start,
340                    available: self.bytes.len(),
341                });
342            }
343            let end = (start + len).min(self.bytes.len());
344            Ok(self.bytes[start..end].to_vec())
345        }
346    }
347
348    /// Lay `attrs` out, write the result into a fresh image, and read them
349    /// back through the dense reader.
350    fn round_trip(attrs: &[AttributeEntry]) -> (MemFile, Vec<AttributeEntry>) {
351        let mut file = MemFile::new();
352        let dense = build_dense_attributes(attrs, &ctx(), CreationOrder::Untracked, &mut |len| {
353            file.alloc(len)
354        })
355        .unwrap();
356        for block in &dense.blocks {
357            assert_eq!(block.len as usize, block.image.len(), "block len vs image");
358            let at = block.addr as usize;
359            file.bytes[at..at + block.image.len()].copy_from_slice(&block.image);
360        }
361        let read = read_dense_attributes(&dense.ainfo, &ctx(), &mut file).unwrap();
362        (file, read)
363    }
364
365    fn numeric(name: &str, value: i32) -> AttributeEntry {
366        AttributeMessage::scalar_numeric(
367            name,
368            DatatypeMessage::i32_type(),
369            value.to_le_bytes().to_vec(),
370        )
371        .into()
372    }
373
374    #[test]
375    fn a_dozen_attributes_round_trip_through_dense_storage() {
376        let attrs: Vec<AttributeEntry> = (0..12).map(|i| numeric(&format!("attr{i}"), i)).collect();
377        let (_file, read) = round_trip(&attrs);
378
379        assert_eq!(read.len(), attrs.len());
380        // The reader returns them in name-index (hash) order, so compare as
381        // sets keyed by name.
382        for want in &attrs {
383            let got = read
384                .iter()
385                .find(|a| a.name() == want.name())
386                .unwrap_or_else(|| panic!("'{}' missing from dense storage", want.name()));
387            assert_eq!(got, want);
388        }
389    }
390
391    #[test]
392    fn an_attribute_past_the_managed_size_round_trips_as_a_huge_object() {
393        // 25600 i32 elements is the `attr_large` oracle case: 100 KiB of data,
394        // far past both the heap's 4 KiB `max_man_size` and the 65535-byte
395        // ceiling an object header message can express.
396        let data: Vec<u8> = (0..25600i32).flat_map(|v| v.to_le_bytes()).collect();
397        let big = AttributeEntry::from(AttributeMessage::array_numeric(
398            "big",
399            DatatypeMessage::i32_type(),
400            &[25600],
401            data,
402        ));
403        assert!(big.encode(&ctx()).len() > 65535);
404
405        let attrs = vec![numeric("small", 7), big];
406        let (_file, read) = round_trip(&attrs);
407
408        assert_eq!(read.len(), 2);
409        for want in &attrs {
410            let got = read.iter().find(|a| a.name() == want.name()).unwrap();
411            assert_eq!(got, want);
412        }
413    }
414
415    #[test]
416    fn an_object_with_no_attributes_yields_an_empty_index() {
417        let (_file, read) = round_trip(&[]);
418        assert!(read.is_empty());
419    }
420
421    /// Tracking on: the name records carry the real creation index instead
422    /// of the "not tracked" sentinel, a second v2 B-tree of type 9 orders the
423    /// same heap IDs by it, and the `Attribute Info` message announces both.
424    #[test]
425    fn a_tracked_object_gets_a_creation_order_index() {
426        // Names deliberately reverse the creation order, so an index built
427        // from the name ordering would show up here.
428        let attrs: Vec<AttributeEntry> = (0..12u16)
429            .map(|i| numeric(&format!("a{:02}", 11 - i), i32::from(i)).with_creation_index(Some(i)))
430            .collect();
431        let mut file = MemFile::new();
432        let dense = build_dense_attributes(&attrs, &ctx(), CreationOrder::Indexed, &mut |len| {
433            file.alloc(len)
434        })
435        .unwrap();
436        for block in &dense.blocks {
437            let at = block.addr as usize;
438            file.bytes[at..at + block.image.len()].copy_from_slice(&block.image);
439        }
440        assert_eq!(dense.ainfo.max_creation_index, Some(12));
441
442        // Reading still works through the name index.
443        let read = read_dense_attributes(&dense.ainfo, &ctx(), &mut file).unwrap();
444        assert_eq!(read.len(), attrs.len());
445
446        let addr = dense
447            .ainfo
448            .creation_order_btree_address
449            .expect("tracked attributes must carry a creation-order index");
450        let bt2 = Bt2Header::decode(&file.read_block(addr, 256).unwrap(), &ctx()).unwrap();
451        assert_eq!(bt2.record_type, BT2_TYPE_ATTR_CORDER);
452        assert_eq!(bt2.record_size as usize, CORDER_RECORD_LEN);
453
454        let records = collect_btree_v2_records(&bt2, &ctx(), &mut file).unwrap();
455        let corders: Vec<u32> = records
456            .as_chunks::<CORDER_RECORD_LEN>()
457            .0
458            .iter()
459            .map(|r| u32::from_le_bytes(r[FHEAP_ID_LEN + 1..CORDER_RECORD_LEN].try_into().unwrap()))
460            .collect();
461        assert_eq!(corders, (0..12u32).collect::<Vec<_>>());
462
463        // The name index carries the same creation indices, not the sentinel.
464        let name_bt2 = Bt2Header::decode(
465            &file
466                .read_block(dense.ainfo.name_btree_address, 256)
467                .unwrap(),
468            &ctx(),
469        )
470        .unwrap();
471        let name_records = collect_btree_v2_records(&name_bt2, &ctx(), &mut file).unwrap();
472        let mut seen: Vec<u32> = name_records
473            .as_chunks::<NAME_RECORD_LEN>()
474            .0
475            .iter()
476            .map(|r| u32::from_le_bytes(r[FHEAP_ID_LEN + 1..FHEAP_ID_LEN + 5].try_into().unwrap()))
477            .collect();
478        seen.sort_unstable();
479        assert_eq!(seen, (0..12u32).collect::<Vec<_>>());
480    }
481
482    /// The index written is the one the attribute carries, never the position
483    /// it happens to hold in the list — the whole point of reading it back off
484    /// the record. A set read out of a file arrives in name-hash order and can
485    /// have gaps where attributes were deleted, so a build that numbered by
486    /// position would renumber every attribute of every reopened object.
487    #[test]
488    fn each_attribute_keeps_the_creation_index_it_carries() {
489        let want = [5u16, 0, 9, 2];
490        let attrs: Vec<AttributeEntry> = want
491            .iter()
492            .enumerate()
493            .map(|(pos, &idx)| {
494                numeric(&format!("n{pos}"), pos as i32).with_creation_index(Some(idx))
495            })
496            .collect();
497        let mut file = MemFile::new();
498        let dense = build_dense_attributes(&attrs, &ctx(), CreationOrder::Indexed, &mut |len| {
499            file.alloc(len)
500        })
501        .unwrap();
502        for block in &dense.blocks {
503            let at = block.addr as usize;
504            file.bytes[at..at + block.image.len()].copy_from_slice(&block.image);
505        }
506
507        // One past the largest, with the gaps left where they are.
508        assert_eq!(dense.ainfo.max_creation_index, Some(10));
509
510        let read = read_dense_attributes(&dense.ainfo, &ctx(), &mut file).unwrap();
511        for attr in &attrs {
512            let got = read.iter().find(|a| a.name() == attr.name()).unwrap();
513            assert_eq!(
514                got.creation_index(),
515                attr.creation_index(),
516                "{}",
517                attr.name()
518            );
519        }
520
521        // The type-9 index is ordered by the index itself, which is what
522        // `H5A__dense_btree2_corder_compare` walks it on.
523        let addr = dense.ainfo.creation_order_btree_address.unwrap();
524        let bt2 = Bt2Header::decode(&file.read_block(addr, 256).unwrap(), &ctx()).unwrap();
525        let records = collect_btree_v2_records(&bt2, &ctx(), &mut file).unwrap();
526        let corders: Vec<u32> = records
527            .as_chunks::<CORDER_RECORD_LEN>()
528            .0
529            .iter()
530            .map(|r| u32::from_le_bytes(r[FHEAP_ID_LEN + 1..CORDER_RECORD_LEN].try_into().unwrap()))
531            .collect();
532        assert_eq!(corders, vec![0u32, 2, 5, 9]);
533    }
534
535    #[test]
536    fn name_records_are_ordered_by_hash() {
537        // Enough attributes that the index is more than one leaf, so a
538        // misordered bulk load would put a record under the wrong subtree.
539        let attrs: Vec<AttributeEntry> = (0..64).map(|i| numeric(&format!("a{i}"), i)).collect();
540        let mut file = MemFile::new();
541        let dense = build_dense_attributes(&attrs, &ctx(), CreationOrder::Untracked, &mut |len| {
542            file.alloc(len)
543        })
544        .unwrap();
545        for block in &dense.blocks {
546            let at = block.addr as usize;
547            file.bytes[at..at + block.image.len()].copy_from_slice(&block.image);
548        }
549
550        let bt2_buf = file
551            .read_block(dense.ainfo.name_btree_address, 256)
552            .unwrap();
553        let bt2 = Bt2Header::decode(&bt2_buf, &ctx()).unwrap();
554        assert!(bt2.depth > 0, "expected a multi-level index, got one leaf");
555        let records = collect_btree_v2_records(&bt2, &ctx(), &mut file).unwrap();
556
557        let hashes: Vec<u32> = records
558            .as_chunks::<NAME_RECORD_LEN>()
559            .0
560            .iter()
561            .map(|r| u32::from_le_bytes(r[13..17].try_into().unwrap()))
562            .collect();
563        assert_eq!(hashes.len(), attrs.len());
564        assert!(
565            hashes.windows(2).all(|w| w[0] <= w[1]),
566            "name index is not hash-ordered: {hashes:?}"
567        );
568        // Every record carries the "no creation index" sentinel.
569        for rec in records.as_chunks::<NAME_RECORD_LEN>().0 {
570            assert_eq!(rec[FHEAP_ID_LEN], 0, "no record is shared");
571            assert_eq!(
572                u32::from_le_bytes(rec[9..13].try_into().unwrap()),
573                u32::from(MAX_CREATION_ORDER_INDEX)
574            );
575        }
576    }
577
578    #[test]
579    fn dense_ainfo_without_name_index_is_an_error() {
580        let ainfo = AttributeInfoMessage {
581            max_creation_index: None,
582            fractal_heap_address: 512,
583            name_btree_address: UNDEF_ADDR,
584            creation_order_btree_address: None,
585        };
586        let mut reader = SliceReader(&[]);
587        let err = read_dense_attributes(&ainfo, &ctx(), &mut reader).unwrap_err();
588        assert!(matches!(err, FormatError::InvalidData(_)));
589    }
590}