Skip to main content

limnifs_core/
metadata_reference.rs

1//! Metadata reference section (spec §5.3,
2//! `bit-level/38-metadata-reference.md`).
3//!
4//! Carries the BLAKE3 hash of the layer-2 metadata blob plus the
5//! locators (or inline bytes) needed to fetch it. The Merkle root
6//! (§5.10) commits to `metadata_hash` directly so swapping the
7//! metadata blob invalidates the root.
8//!
9//! ## Section versions
10//!
11//! - **v1** (original): inline bytes are the uncompressed metadata
12//!   blob. Locators (when present) reference an uncompressed
13//!   sidecar file.
14//! - **v2** (current default for writers): adds a `codec` byte so
15//!   the inline bytes (or the sidecar file) can be compressed. The
16//!   `metadata_hash` is still BLAKE3 of the **uncompressed** blob;
17//!   readers decompress before verifying. v2 is a strict superset of
18//!   v1 for readers — old readers reject v2 with `UnsupportedFeature`,
19//!   new readers handle both.
20
21use crate::cursor::ManifestCursor;
22use crate::error::CoreError;
23use crate::locator::{
24    parse_locator_entries_with_ceiling, LocatorEntry, DEFAULT_LOCATOR_MAX_URI_BYTES,
25};
26
27/// v1 layout (uncompressed inline/external blob).
28pub const METADATA_REFERENCE_SECTION_VERSION: u8 = 1;
29
30/// v2 layout (adds `uncompressed_len` + `codec` for compressed blobs).
31pub const METADATA_REFERENCE_SECTION_VERSION_2: u8 = 2;
32
33/// Codec id meaning "no compression / stored verbatim". Matches
34/// [`crate::codec::CODEC_STORE`].
35const CODEC_STORE: u8 = 0x00;
36
37/// Default ceiling on the **compressed** INLINE metadata length (per
38/// spec §5.3: "metadata blob ≤ 1 MiB by default"). The uncompressed
39/// length is bounded separately and may exceed this when a high-
40/// compression codec is in use. Caller can override via
41/// [`parse_metadata_reference_with_ceilings`].
42///
43/// **This gates INLINE metadata only** (bytes carried inside the
44/// manifest, parsed before the reader knows anything about the
45/// image). EXTERNAL metadata — the `file:metadata.bin` sidecar — is
46/// a separate file the opener chose to read and has NO ceiling in
47/// the reference load path ([`read_external_metadata`]); use its
48/// file size as the bound. Do not apply this constant to sidecars
49/// (issue #191: a downstream driver did, rejecting every large-tree
50/// image its own format could carry).
51pub const DEFAULT_INLINE_METADATA_MAX_BYTES: u32 = 1024 * 1024;
52
53/// Width of the fixed prefix of the v1 section: 1-byte `version` +
54/// 32-byte `hash` + 4-byte `locator_count`.
55const V1_PREFIX_LEN: usize = 1 + 32 + 4;
56
57/// Width of the fixed prefix of the v2 section: v1 prefix +
58/// 4-byte `uncompressed_len` + 1-byte `codec`.
59const V2_EXTRA_PREFIX_LEN: usize = 4 + 1;
60
61/// Parsed metadata reference section. The `inline_metadata` field
62/// always holds **uncompressed** bytes when present (the parser
63/// decompresses v2 blobs transparently). The `codec` field records
64/// what was on the wire so callers can re-emit it without
65/// re-compression.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct MetadataReference {
68    pub metadata_hash: [u8; 32],
69    pub locators: Vec<LocatorEntry>,
70    pub inline_metadata: Option<Vec<u8>>,
71    /// Codec id used for the inline/external blob on the wire.
72    /// `0x00` for v1 (always store). For v2, whatever the writer
73    /// chose (typically `0x04` Brotli for source-tree metadata).
74    pub codec: u8,
75    /// Uncompressed byte length of the metadata blob. Equal to
76    /// `inline_metadata.len()` when inline; informative for
77    /// external (locator) blobs.
78    pub uncompressed_len: u32,
79}
80
81impl MetadataReference {
82    /// True iff the metadata blob is inlined in this section. Readers
83    /// can skip the locator layer when this returns true.
84    #[must_use]
85    pub fn is_inlined(&self) -> bool {
86        self.inline_metadata.is_some()
87    }
88}
89
90impl Default for MetadataReference {
91    fn default() -> Self {
92        Self {
93            metadata_hash: [0u8; 32],
94            locators: Vec::new(),
95            inline_metadata: None,
96            codec: CODEC_STORE,
97            uncompressed_len: 0,
98        }
99    }
100}
101
102/// Parse the metadata reference section from the cursor's current
103/// position. Uses the default ceilings (4 KiB per locator URI, 1 MiB
104/// inline metadata).
105///
106/// # Errors
107///
108/// - [`CoreError::UnsupportedFeature`] if `section_version` is neither
109///   1 nor 2.
110/// - [`CoreError::Corrupt`] if both `locators` and `inline_metadata`
111///   are absent (unreachable metadata), if any structural check fails,
112///   or if v2 decompression fails.
113/// - Inherits errors from [`crate::locator::parse_locator_entries`].
114pub fn parse_metadata_reference(
115    cursor: &mut ManifestCursor<'_>,
116) -> Result<MetadataReference, CoreError> {
117    parse_metadata_reference_with_ceilings(
118        cursor,
119        DEFAULT_LOCATOR_MAX_URI_BYTES,
120        DEFAULT_INLINE_METADATA_MAX_BYTES,
121    )
122}
123
124/// Same as [`parse_metadata_reference`] but with caller-supplied
125/// ceilings for the per-locator URI byte length and the **on-wire**
126/// (possibly compressed) inline metadata blob length.
127///
128/// # Errors
129///
130/// Inherits all errors from [`parse_metadata_reference`].
131pub fn parse_metadata_reference_with_ceilings(
132    cursor: &mut ManifestCursor<'_>,
133    max_locator_uri_bytes: u32,
134    max_inline_metadata_bytes: u32,
135) -> Result<MetadataReference, CoreError> {
136    let section_version = cursor.read_u8()?;
137    match section_version {
138        METADATA_REFERENCE_SECTION_VERSION => {
139            parse_v1(cursor, max_locator_uri_bytes, max_inline_metadata_bytes)
140        }
141        METADATA_REFERENCE_SECTION_VERSION_2 => {
142            parse_v2(cursor, max_locator_uri_bytes, max_inline_metadata_bytes)
143        }
144        other => Err(CoreError::UnsupportedFeature {
145            feature: format!("metadata_reference section version {other} (supported: 1, 2)"),
146        }),
147    }
148}
149
150fn parse_v1(
151    cursor: &mut ManifestCursor<'_>,
152    max_locator_uri_bytes: u32,
153    max_inline_metadata_bytes: u32,
154) -> Result<MetadataReference, CoreError> {
155    let metadata_hash = read_hash(cursor)?;
156    let locator_count = cursor.read_u32_le()?;
157    let locators =
158        parse_locator_entries_with_ceiling(cursor, locator_count, max_locator_uri_bytes)?;
159    let inline_metadata_len = cursor.read_u32_le()?;
160    let inline_metadata = read_inline_blob(
161        cursor,
162        inline_metadata_len,
163        max_inline_metadata_bytes,
164        inline_metadata_len,
165        CODEC_STORE,
166    )?;
167    if locators.is_empty() && inline_metadata.is_none() {
168        return Err(unreachable_error(V1_PREFIX_LEN));
169    }
170    Ok(MetadataReference {
171        metadata_hash,
172        locators,
173        inline_metadata,
174        codec: CODEC_STORE,
175        uncompressed_len: inline_metadata_len,
176    })
177}
178
179fn parse_v2(
180    cursor: &mut ManifestCursor<'_>,
181    max_locator_uri_bytes: u32,
182    max_inline_metadata_bytes: u32,
183) -> Result<MetadataReference, CoreError> {
184    let metadata_hash = read_hash(cursor)?;
185    let uncompressed_len = cursor.read_u32_le()?;
186    let codec = cursor.read_u8()?;
187    let locator_count = cursor.read_u32_le()?;
188    let locators =
189        parse_locator_entries_with_ceiling(cursor, locator_count, max_locator_uri_bytes)?;
190    let inline_data_len = cursor.read_u32_le()?;
191    let inline_metadata = read_inline_blob(
192        cursor,
193        inline_data_len,
194        max_inline_metadata_bytes,
195        uncompressed_len,
196        codec,
197    )?;
198    if locators.is_empty() && inline_metadata.is_none() {
199        return Err(unreachable_error(V1_PREFIX_LEN + V2_EXTRA_PREFIX_LEN));
200    }
201    Ok(MetadataReference {
202        metadata_hash,
203        locators,
204        inline_metadata,
205        codec,
206        uncompressed_len,
207    })
208}
209
210fn read_hash(cursor: &mut ManifestCursor<'_>) -> Result<[u8; 32], CoreError> {
211    let hash_bytes = cursor.read_n(32)?;
212    let mut metadata_hash = [0u8; 32];
213    metadata_hash.copy_from_slice(hash_bytes);
214    Ok(metadata_hash)
215}
216
217/// Read `inline_data_len` bytes from the cursor; if `codec != STORE`,
218/// decompress to `uncompressed_len` bytes. Returns `None` if
219/// `inline_data_len == 0`. Verifies the decompressed length matches
220/// `uncompressed_len`.
221fn read_inline_blob(
222    cursor: &mut ManifestCursor<'_>,
223    inline_data_len: u32,
224    max_inline_metadata_bytes: u32,
225    uncompressed_len: u32,
226    codec: u8,
227) -> Result<Option<Vec<u8>>, CoreError> {
228    if inline_data_len == 0 {
229        return Ok(None);
230    }
231    if inline_data_len > max_inline_metadata_bytes {
232        return Err(CoreError::Corrupt {
233            reason: format!(
234                "metadata_reference inline_data_len {inline_data_len} exceeds ceiling {max_inline_metadata_bytes}"
235            ),
236        });
237    }
238    let wire_len = usize::try_from(inline_data_len).map_err(|_| CoreError::Corrupt {
239        reason: format!("metadata_reference inline_data_len {inline_data_len} exceeds usize"),
240    })?;
241    let wire_bytes = cursor.read_n_owned(wire_len)?;
242    if codec == CODEC_STORE {
243        return Ok(Some(wire_bytes));
244    }
245    // Compressed: dispatch to the codec registry.
246    let uncompressed =
247        crate::codec::decompress(codec, &wire_bytes, uncompressed_len).map_err(|e| {
248            CoreError::Corrupt {
249                reason: format!("metadata_reference: codec 0x{codec:02X} decompress failed: {e}"),
250            }
251        })?;
252    let got = u32::try_from(uncompressed.len()).unwrap_or(u32::MAX);
253    if got != uncompressed_len {
254        return Err(CoreError::Corrupt {
255            reason: format!(
256                "metadata_reference: decompressed length {got} does not match declared uncompressed_len {uncompressed_len}"
257            ),
258        });
259    }
260    Ok(Some(uncompressed))
261}
262
263fn unreachable_error(prefix_len: usize) -> CoreError {
264    CoreError::Corrupt {
265        reason: format!(
266            "metadata_reference is unreachable: locator_count=0 and inline_data_len=0 (need at least one source for the {prefix_len}-byte metadata blob)"
267        ),
268    }
269}
270
271/// Load the metadata blob bytes for an image whose reference section
272/// parsed to external locators: follow the first `file:` locator
273/// (resolved relative to the image file's directory), then decompress
274/// per the reference's codec field (codec 0 = STORE returns the raw
275/// bytes). Images with INLINE metadata don't call this — their bytes
276/// already live in [`MetadataReference::inline_metadata`].
277///
278/// This is the one true load path for external metadata. It applies
279/// NO size ceiling: the sidecar is a file the caller chose to open,
280/// so its on-disk size is the bound — unlike INLINE metadata, where
281/// [`DEFAULT_INLINE_METADATA_MAX_BYTES`] protects the unbounded
282/// manifest read. Verified at 150,000 inodes / 616 MiB sidecar
283/// (issue #191).
284///
285/// # Errors
286///
287/// - [`CoreError::Corrupt`] if the reference carries no locators, or
288///   if the locator is not a `file:` URI.
289/// - [`CoreError::Io`]-shaped [`CoreError::Corrupt`] if the sidecar
290///   cannot be read, or v2 decompression fails.
291pub fn read_external_metadata(
292    reference: &MetadataReference,
293    image_path: &std::path::Path,
294) -> Result<Vec<u8>, CoreError> {
295    let entry = reference
296        .locators
297        .first()
298        .ok_or_else(|| CoreError::Corrupt {
299            reason: "metadata_reference has neither inline data nor locators".into(),
300        })?;
301    let name = crate::locator::local_sidecar_name(&entry.uri)?;
302    let sidecar_path = image_path
303        .parent()
304        .unwrap_or_else(|| std::path::Path::new("."))
305        .join(name);
306    let wire = std::fs::read(&sidecar_path).map_err(|e| CoreError::Corrupt {
307        reason: format!("read external metadata {}: {e}", sidecar_path.display()),
308    })?;
309    if reference.codec == 0 {
310        Ok(wire)
311    } else {
312        crate::codec::decompress(reference.codec, &wire, reference.uncompressed_len)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn make_locator_bytes(uri: &str) -> Vec<u8> {
321        let length = u32::try_from(uri.len()).expect("test URI fits u32");
322        let mut bytes = Vec::with_capacity(4 + uri.len());
323        bytes.extend_from_slice(&length.to_le_bytes());
324        bytes.extend_from_slice(uri.as_bytes());
325        bytes
326    }
327
328    fn make_metadata_reference_bytes(
329        version: u8,
330        metadata_hash: [u8; 32],
331        locator_uris: &[&str],
332        inline_metadata: Option<&[u8]>,
333    ) -> Vec<u8> {
334        let mut bytes = Vec::new();
335        bytes.push(version);
336        bytes.extend_from_slice(&metadata_hash);
337        let locator_count = u32::try_from(locator_uris.len()).expect("count fits u32");
338        bytes.extend_from_slice(&locator_count.to_le_bytes());
339        for uri in locator_uris {
340            bytes.extend(make_locator_bytes(uri));
341        }
342        let inline_len =
343            inline_metadata.map_or(0u32, |b| u32::try_from(b.len()).expect("len fits u32"));
344        bytes.extend_from_slice(&inline_len.to_le_bytes());
345        if let Some(blob) = inline_metadata {
346            bytes.extend_from_slice(blob);
347        }
348        bytes
349    }
350
351    fn sample_hash() -> [u8; 32] {
352        [0xAA; 32]
353    }
354
355    #[test]
356    fn parses_external_metadata_single_locator() {
357        let bytes = make_metadata_reference_bytes(
358            METADATA_REFERENCE_SECTION_VERSION,
359            sample_hash(),
360            &["file:///var/lib/limnifs/metadata.bin"],
361            None,
362        );
363        let mut cursor = ManifestCursor::new(&bytes);
364        let parsed = parse_metadata_reference(&mut cursor).expect("external parses");
365        assert_eq!(parsed.metadata_hash, sample_hash());
366        assert_eq!(parsed.locators.len(), 1);
367        assert_eq!(parsed.locators[0].scheme(), Some("file"));
368        assert!(parsed.inline_metadata.is_none());
369        assert!(!parsed.is_inlined());
370        assert_eq!(cursor.position(), bytes.len());
371    }
372
373    #[test]
374    fn parses_inlined_metadata() {
375        let blob = vec![0xBB; 1024];
376        let bytes = make_metadata_reference_bytes(
377            METADATA_REFERENCE_SECTION_VERSION,
378            sample_hash(),
379            &[],
380            Some(&blob),
381        );
382        let mut cursor = ManifestCursor::new(&bytes);
383        let parsed = parse_metadata_reference(&mut cursor).expect("inlined parses");
384        assert_eq!(parsed.locators.len(), 0);
385        assert_eq!(parsed.inline_metadata.as_deref(), Some(&blob[..]));
386        assert!(parsed.is_inlined());
387    }
388
389    #[test]
390    fn parses_mirrored_with_inline_fallback() {
391        let blob = vec![0xCC; 4096];
392        let bytes = make_metadata_reference_bytes(
393            METADATA_REFERENCE_SECTION_VERSION,
394            sample_hash(),
395            &["https://cdn/x.bin", "s3://bucket/x.bin"],
396            Some(&blob),
397        );
398        let mut cursor = ManifestCursor::new(&bytes);
399        let parsed = parse_metadata_reference(&mut cursor).expect("mirrored parses");
400        assert_eq!(parsed.locators.len(), 2);
401        assert_eq!(parsed.locators[0].scheme(), Some("https"));
402        assert_eq!(parsed.locators[1].scheme(), Some("s3"));
403        assert!(parsed.inline_metadata.is_some());
404    }
405
406    #[test]
407    fn rejects_unknown_section_version() {
408        let bytes = make_metadata_reference_bytes(7, sample_hash(), &["file:///x"], None);
409        let mut cursor = ManifestCursor::new(&bytes);
410        match parse_metadata_reference(&mut cursor) {
411            Err(CoreError::UnsupportedFeature { feature }) => {
412                assert!(feature.contains("version 7"), "got: {feature}");
413            }
414            other => panic!("expected UnsupportedFeature, got {other:?}"),
415        }
416    }
417
418    #[test]
419    fn rejects_unreachable_metadata() {
420        let bytes = make_metadata_reference_bytes(
421            METADATA_REFERENCE_SECTION_VERSION,
422            sample_hash(),
423            &[],
424            None,
425        );
426        let mut cursor = ManifestCursor::new(&bytes);
427        match parse_metadata_reference(&mut cursor) {
428            Err(CoreError::Corrupt { reason }) => {
429                assert!(reason.contains("unreachable"), "got: {reason}");
430            }
431            other => panic!("expected Corrupt, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn rejects_inline_above_default_ceiling() {
437        let oversized = vec![0xDD; (DEFAULT_INLINE_METADATA_MAX_BYTES as usize) + 1];
438        let bytes = make_metadata_reference_bytes(
439            METADATA_REFERENCE_SECTION_VERSION,
440            sample_hash(),
441            &[],
442            Some(&oversized),
443        );
444        let mut cursor = ManifestCursor::new(&bytes);
445        match parse_metadata_reference(&mut cursor) {
446            Err(CoreError::Corrupt { reason }) => {
447                assert!(reason.contains("ceiling"), "got: {reason}");
448            }
449            other => panic!("expected Corrupt, got {other:?}"),
450        }
451    }
452
453    #[test]
454    fn custom_ceiling_accepts_oversized_inline() {
455        let oversized = vec![0xEE; (DEFAULT_INLINE_METADATA_MAX_BYTES as usize) + 1024];
456        let bytes = make_metadata_reference_bytes(
457            METADATA_REFERENCE_SECTION_VERSION,
458            sample_hash(),
459            &[],
460            Some(&oversized),
461        );
462        let mut cursor = ManifestCursor::new(&bytes);
463        let parsed = parse_metadata_reference_with_ceilings(
464            &mut cursor,
465            DEFAULT_LOCATOR_MAX_URI_BYTES,
466            2 * DEFAULT_INLINE_METADATA_MAX_BYTES,
467        )
468        .expect("custom ceiling accepts");
469        assert_eq!(parsed.inline_metadata.as_deref(), Some(&oversized[..]));
470    }
471
472    #[test]
473    fn rejects_truncated_prefix() {
474        // Valid version + partial hash. Cursor returns TooShort when
475        // it reaches the missing bytes of the 32-byte hash.
476        let mut bytes = vec![METADATA_REFERENCE_SECTION_VERSION];
477        bytes.extend_from_slice(&[0u8; 30]); // 30 bytes of hash, need 32
478        let mut cursor = ManifestCursor::new(&bytes);
479        match parse_metadata_reference(&mut cursor) {
480            Err(CoreError::TooShort { .. }) => {}
481            other => panic!("expected TooShort, got {other:?}"),
482        }
483    }
484
485    #[test]
486    fn rejects_bad_locator_entry_inherited() {
487        // Locator with no colon; the inner error should propagate.
488        let mut bytes = Vec::new();
489        bytes.push(METADATA_REFERENCE_SECTION_VERSION);
490        bytes.extend_from_slice(&sample_hash());
491        bytes.extend_from_slice(&1u32.to_le_bytes()); // locator_count = 1
492        bytes.extend_from_slice(&5u32.to_le_bytes()); // length = 5
493        bytes.extend_from_slice(b"abcde"); // no colon
494        bytes.extend_from_slice(&0u32.to_le_bytes()); // inline = 0
495        let mut cursor = ManifestCursor::new(&bytes);
496        match parse_metadata_reference(&mut cursor) {
497            Err(CoreError::Corrupt { reason }) => {
498                assert!(reason.contains("separator"), "got: {reason}");
499            }
500            other => panic!("expected Corrupt, got {other:?}"),
501        }
502    }
503
504    /// Build a v2 `metadata_reference` section with `codec` + the given
505    /// on-wire bytes.
506    fn make_v2_bytes(
507        metadata_hash: [u8; 32],
508        uncompressed_len: u32,
509        codec: u8,
510        locator_uris: &[&str],
511        inline_data: Option<&[u8]>,
512    ) -> Vec<u8> {
513        let mut bytes = Vec::new();
514        bytes.push(METADATA_REFERENCE_SECTION_VERSION_2);
515        bytes.extend_from_slice(&metadata_hash);
516        bytes.extend_from_slice(&uncompressed_len.to_le_bytes());
517        bytes.push(codec);
518        let locator_count = u32::try_from(locator_uris.len()).expect("count fits u32");
519        bytes.extend_from_slice(&locator_count.to_le_bytes());
520        for uri in locator_uris {
521            bytes.extend(make_locator_bytes(uri));
522        }
523        let inline_len =
524            inline_data.map_or(0u32, |b| u32::try_from(b.len()).expect("len fits u32"));
525        bytes.extend_from_slice(&inline_len.to_le_bytes());
526        if let Some(blob) = inline_data {
527            bytes.extend(blob);
528        }
529        bytes
530    }
531
532    #[test]
533    fn v2_store_codec_round_trips() {
534        let blob = b"hello metadata blob world";
535        let hash = crate::merkle::hash_section(blob);
536        let bytes = make_v2_bytes(hash, blob.len() as u32, 0x00, &[], Some(blob));
537        let mut cursor = ManifestCursor::new(&bytes);
538        let parsed = parse_metadata_reference(&mut cursor).expect("v2 parses");
539        assert_eq!(parsed.metadata_hash, hash);
540        assert_eq!(parsed.codec, 0x00);
541        assert_eq!(parsed.uncompressed_len, u32::try_from(blob.len()).unwrap());
542        assert_eq!(parsed.inline_metadata.as_deref(), Some(blob.as_slice()));
543    }
544
545    #[test]
546    fn v2_brotli_codec_decompresses_inline() {
547        let blob = b"the quick brown fox jumps over the lazy dog".repeat(50);
548        let hash = crate::merkle::hash_section(&blob);
549        // Compress with the registry's brotli codec (CODEC_BROTLI = 0x04).
550        let compressed = crate::codec::compress(0x04, &blob).expect("brotli compress");
551        assert!(
552            compressed.len() < blob.len(),
553            "brotli should beat store on repetitive input"
554        );
555        let bytes = make_v2_bytes(
556            hash,
557            u32::try_from(blob.len()).unwrap(),
558            0x04,
559            &[],
560            Some(&compressed),
561        );
562        let mut cursor = ManifestCursor::new(&bytes);
563        let parsed = parse_metadata_reference(&mut cursor).expect("v2 brotli parses");
564        assert_eq!(parsed.codec, 0x04);
565        assert_eq!(parsed.uncompressed_len, u32::try_from(blob.len()).unwrap());
566        assert_eq!(parsed.inline_metadata.as_deref(), Some(blob.as_slice()));
567    }
568
569    #[test]
570    fn v2_rejects_uncompressed_length_mismatch() {
571        let blob = b"hello";
572        let compressed = crate::codec::compress(0x04, blob).expect("brotli");
573        // Lie about uncompressed_len: claim 999 instead of 5.
574        let hash = crate::merkle::hash_section(blob);
575        let bytes = make_v2_bytes(hash, 999, 0x04, &[], Some(&compressed));
576        let mut cursor = ManifestCursor::new(&bytes);
577        match parse_metadata_reference(&mut cursor) {
578            Err(CoreError::Corrupt { reason }) => {
579                // Either the codec rejects the wrong expected_len, or
580                // our post-decode length check fires. Both are
581                // acceptable rejections of the inconsistent input.
582                assert!(
583                    reason.contains("length") || reason.contains("decompress"),
584                    "got: {reason}"
585                );
586            }
587            other => panic!("expected Corrupt, got {other:?}"),
588        }
589    }
590
591    #[test]
592    fn unknown_section_version_above_2_rejected() {
593        let bytes = make_metadata_reference_bytes(99, [0u8; 32], &[], Some(b"inline blob"));
594        let mut cursor = ManifestCursor::new(&bytes);
595        match parse_metadata_reference(&mut cursor) {
596            Err(CoreError::UnsupportedFeature { feature }) => {
597                assert!(feature.contains("version 99"), "got: {feature}");
598            }
599            other => panic!("expected UnsupportedFeature, got {other:?}"),
600        }
601    }
602}