Skip to main content

concinnity_core/blob/
parse.rs

1// The blob decode half: turn blob bytes back into metadata. Reading those bytes
2// off disk is the caller's job, so the payload residency store lives in
3// concinnity-core and the state root's `data/` layout in `concinnity_host::store`.
4
5use crate::blob::HEADER_SIZE;
6use crate::blob::error::BlobError;
7use crate::blob::frame::{FrameError, decode_exact};
8use crate::blob::kind::BlobKind;
9
10/// Parse a blob image's header and metadata block. Returns the metadata and the
11/// offset at which the payload section begins.
12///
13/// `K` decides both the magic the header must carry and the type the metadata
14/// block decodes into, so an image written for another kind is rejected as
15/// [`BlobError::BadMagic`] rather than decoded into the wrong shape.
16///
17/// `validity` is what the header's token must equal for the image to be
18/// readable by this build. What it means belongs to the kind: for
19/// [`BlobMeta`](crate::blob::BlobMeta) it is a schema version, and runtime
20/// callers pass `concinnity_core::SCHEMA_VERSION`.
21pub fn parse_cnb<K: BlobKind>(validity: u32, data: &[u8]) -> Result<(K, usize), BlobError> {
22    let meta_len = parse_header::<K>(data)? as usize;
23
24    let stored = le_u32(data, 4).ok_or(BlobError::TooShort)?;
25    if stored != validity {
26        return Err(BlobError::ValidityMismatch(stored));
27    }
28
29    let meta_end = HEADER_SIZE
30        .checked_add(meta_len)
31        .ok_or(BlobError::TruncatedMeta)?;
32    let meta_bytes = data
33        .get(HEADER_SIZE..meta_end)
34        .ok_or(BlobError::TruncatedMeta)?;
35    let meta = decode_exact(meta_bytes).map_err(|e| match e {
36        FrameError::Decode(_) => BlobError::Decode,
37        FrameError::Trailing(n) => BlobError::TrailingMeta(n),
38    })?;
39    Ok((meta, meta_end))
40}
41
42/// Payload-section offset read from the header alone, so a caller holding only
43/// the first HEADER_SIZE bytes can turn a `PayloadLocator` offset into an
44/// absolute file offset without loading the image.
45pub fn parse_payload_section_start<K: BlobKind>(header: &[u8]) -> Result<u64, BlobError> {
46    Ok(HEADER_SIZE as u64 + parse_header::<K>(header)?)
47}
48
49/// The payload section of a full blob image.
50///
51/// Infallible and lenient: an image too short to hold a header, or one whose
52/// header points past its end, yields an empty section. Overflow blobs carry no
53/// metadata and reach here without a magic or validity check, so this is the one
54/// container read that needs no kind.
55pub fn payload_section(data: &[u8]) -> &[u8] {
56    let Some(meta_len) = le_u64(data, 8) else {
57        return &[];
58    };
59    let meta_len = meta_len as usize;
60    HEADER_SIZE
61        .checked_add(meta_len)
62        .and_then(|start| data.get(start..))
63        .unwrap_or(&[])
64}
65
66// Validate the kind's magic and return the declared metadata length. The
67// validity token is checked only where the metadata is actually decoded.
68fn parse_header<K: BlobKind>(data: &[u8]) -> Result<u64, BlobError> {
69    if data.len() < HEADER_SIZE {
70        return Err(BlobError::TooShort);
71    }
72    if data.get(..4) != Some(&K::MAGIC[..]) {
73        return Err(BlobError::BadMagic);
74    }
75    le_u64(data, 8).ok_or(BlobError::TooShort)
76}
77
78// The little-endian u32 at `at`, or `None` if the buffer ends first.
79fn le_u32(data: &[u8], at: usize) -> Option<u32> {
80    data.get(at..)?
81        .first_chunk::<4>()
82        .copied()
83        .map(u32::from_le_bytes)
84}
85
86// The little-endian u64 at `at`, or `None` if the buffer ends first.
87fn le_u64(data: &[u8], at: usize) -> Option<u64> {
88    data.get(at..)?
89        .first_chunk::<8>()
90        .copied()
91        .map(u64::from_le_bytes)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::blob::cache::CacheMeta;
98    use crate::blob::encode::encode_cnb;
99    use crate::blob::schema::{AssetKind, BlobAssetDef, BlobMeta, ResourceKind, ResourceRecord};
100    use crate::blob::{BLOB_MAGIC, kind::BlobKind};
101    use crate::ecs::PayloadLocator;
102    use alloc::vec;
103    use alloc::vec::Vec;
104
105    // Any value both sides agree on exercises the header check; the real
106    // one is `crate::SCHEMA_VERSION`.
107    const TEST_SCHEMA_VERSION: u32 = 0x1234_5678;
108
109    fn def(discriminant: u8, args_bytes: Vec<u8>) -> BlobAssetDef {
110        BlobAssetDef {
111            name: None,
112            kind: AssetKind::Component,
113            discriminant,
114            args_bytes,
115            payload: None,
116        }
117    }
118
119    fn meta() -> BlobMeta {
120        let defs = vec![def(3, vec![1, 2]), def(9, vec![])];
121        let resources = vec![ResourceRecord {
122            resource_kind: ResourceKind::AudioClip as u8,
123            handle: 0,
124            payload: Some(PayloadLocator {
125                blob_index: 0,
126                offset: 0,
127                len: 3,
128            }),
129            data_bytes: Vec::new(),
130        }];
131        let manifest = crate::blob::WorldManifest::from_records(&defs, &resources);
132        BlobMeta {
133            defs,
134            resources,
135            manifest,
136            scene_groups: Vec::new(),
137            mesh_bounds: Vec::new(),
138            physics_budget: None,
139        }
140    }
141
142    #[test]
143    fn encode_round_trips_defs_resources_and_payload() {
144        let m = meta();
145        let payload = [0xAA, 0xBB, 0xCC];
146        let image = encode_cnb(TEST_SCHEMA_VERSION, &m, &payload).unwrap();
147
148        let (got, payload_start) =
149            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &image).expect("parse");
150        assert_eq!(got, m);
151        assert_eq!(&image[payload_start..], &payload);
152        assert_eq!(
153            parse_payload_section_start::<BlobMeta>(&image).unwrap(),
154            payload_start as u64
155        );
156        assert_eq!(payload_section(&image), &payload);
157    }
158
159    #[test]
160    fn encode_with_no_metadata_and_no_payload_is_parseable() {
161        let image = encode_cnb(TEST_SCHEMA_VERSION, &BlobMeta::default(), &[]).unwrap();
162        let (m, payload_start) = parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &image).expect("parse");
163        assert!(m.defs.is_empty());
164        assert!(m.resources.is_empty());
165        assert_eq!(image.len(), payload_start);
166        assert_eq!(payload_section(&image), &[] as &[u8]);
167    }
168
169    #[test]
170    fn encode_emits_magic_and_validity_token_header() {
171        let image = encode_cnb(TEST_SCHEMA_VERSION, &BlobMeta::default(), &[1]).unwrap();
172        assert_eq!(&image[0..4], &BLOB_MAGIC);
173        assert_eq!(
174            u32::from_le_bytes(image[4..8].try_into().unwrap()),
175            TEST_SCHEMA_VERSION
176        );
177        let meta_len = u64::from_le_bytes(image[8..16].try_into().unwrap()) as usize;
178        assert_eq!(image.len(), HEADER_SIZE + meta_len + 1);
179    }
180
181    // The reason the magic hangs off the kind: a container written for one kind
182    // must not decode as another, even when both metadata types would accept
183    // the bytes.
184    #[test]
185    fn a_container_of_another_kind_is_rejected() {
186        let other = encode_cnb(TEST_SCHEMA_VERSION, &CacheMeta::default(), &[]).unwrap();
187        assert_eq!(&other[0..4], &CacheMeta::MAGIC);
188        assert_eq!(
189            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &other),
190            Err(BlobError::BadMagic)
191        );
192        assert_eq!(
193            parse_payload_section_start::<BlobMeta>(&other),
194            Err(BlobError::BadMagic)
195        );
196
197        let world = encode_cnb(TEST_SCHEMA_VERSION, &meta(), &[]).unwrap();
198        assert_eq!(
199            parse_cnb::<CacheMeta>(TEST_SCHEMA_VERSION, &world),
200            Err(BlobError::BadMagic)
201        );
202    }
203
204    #[test]
205    fn parse_rejects_short_bad_magic_and_validity_mismatch() {
206        assert_eq!(
207            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &[0u8; HEADER_SIZE - 1]),
208            Err(BlobError::TooShort)
209        );
210        assert_eq!(
211            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &[0u8; HEADER_SIZE]),
212            Err(BlobError::BadMagic)
213        );
214
215        let mut mismatched = encode_cnb(TEST_SCHEMA_VERSION, &BlobMeta::default(), &[]).unwrap();
216        let stored = TEST_SCHEMA_VERSION.wrapping_add(1);
217        mismatched[4..8].copy_from_slice(&stored.to_le_bytes());
218        assert_eq!(
219            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &mismatched),
220            Err(BlobError::ValidityMismatch(stored))
221        );
222    }
223
224    // A meta block written by a schema carrying more than this build reads back
225    // decodes cleanly under plain postcard, leaving the tail unread. Widening
226    // the block without changing its content is that shape.
227    #[test]
228    fn parse_rejects_a_meta_section_with_unread_bytes() {
229        let image = encode_cnb(TEST_SCHEMA_VERSION, &meta(), &[]).unwrap();
230        let meta_len = u64::from_le_bytes(image[8..16].try_into().unwrap());
231
232        let mut widened = image.clone();
233        widened[8..16].copy_from_slice(&(meta_len + 2).to_le_bytes());
234        widened.extend_from_slice(&[0, 0]);
235
236        assert_eq!(
237            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &widened),
238            Err(BlobError::TrailingMeta(2))
239        );
240    }
241
242    #[test]
243    fn parse_rejects_a_truncated_meta_section() {
244        let image = encode_cnb(TEST_SCHEMA_VERSION, &meta(), &[]).unwrap();
245        assert_eq!(
246            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, &image[..image.len() - 1]),
247            Err(BlobError::TruncatedMeta)
248        );
249    }
250
251    #[test]
252    fn parse_rejects_a_non_blob_image() {
253        let garbage = b"this is not a blob file at all";
254        assert_eq!(
255            parse_cnb::<BlobMeta>(TEST_SCHEMA_VERSION, garbage),
256            Err(BlobError::BadMagic)
257        );
258        assert_eq!(
259            parse_payload_section_start::<BlobMeta>(garbage),
260            Err(BlobError::BadMagic)
261        );
262    }
263
264    #[test]
265    fn payload_section_is_empty_for_a_headerless_or_overrun_image() {
266        assert_eq!(payload_section(&[]), &[] as &[u8]);
267        assert_eq!(payload_section(&[0u8; HEADER_SIZE - 1]), &[] as &[u8]);
268
269        // header declaring more metadata than the image carries
270        let mut overrun = [0u8; HEADER_SIZE];
271        overrun[0..4].copy_from_slice(&BLOB_MAGIC);
272        overrun[8..16].copy_from_slice(&u64::MAX.to_le_bytes());
273        assert_eq!(payload_section(&overrun), &[] as &[u8]);
274    }
275
276    // Overflow blobs carry no metadata and are read for payload only, so the
277    // section read must not depend on a valid magic.
278    #[test]
279    fn payload_section_ignores_magic() {
280        let mut image = encode_cnb(TEST_SCHEMA_VERSION, &BlobMeta::default(), b"overflow").unwrap();
281        image[0..4].copy_from_slice(b"XXXX");
282        assert_eq!(payload_section(&image), b"overflow");
283    }
284}