1use crate::cursor::ManifestCursor;
22use crate::error::CoreError;
23use crate::locator::{
24 parse_locator_entries_with_ceiling, LocatorEntry, DEFAULT_LOCATOR_MAX_URI_BYTES,
25};
26
27pub const METADATA_REFERENCE_SECTION_VERSION: u8 = 1;
29
30pub const METADATA_REFERENCE_SECTION_VERSION_2: u8 = 2;
32
33const CODEC_STORE: u8 = 0x00;
36
37pub const DEFAULT_INLINE_METADATA_MAX_BYTES: u32 = 1024 * 1024;
52
53const V1_PREFIX_LEN: usize = 1 + 32 + 4;
56
57const V2_EXTRA_PREFIX_LEN: usize = 4 + 1;
60
61#[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 pub codec: u8,
75 pub uncompressed_len: u32,
79}
80
81impl MetadataReference {
82 #[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
102pub 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
124pub 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
217fn 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 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
271pub 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 let mut bytes = vec![METADATA_REFERENCE_SECTION_VERSION];
477 bytes.extend_from_slice(&[0u8; 30]); 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 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()); bytes.extend_from_slice(&5u32.to_le_bytes()); bytes.extend_from_slice(b"abcde"); bytes.extend_from_slice(&0u32.to_le_bytes()); 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 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 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 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 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}