Skip to main content

qualia_core_db/container_10d/
provenance_section.rs

1//! `.10d` ProvenanceSidecar section (P1) — the provenance half of an asset,
2//! bundled **physically inside the container** so context is byte-inseparable
3//! from the data it attests.
4//!
5//! The hypermedia library records provenance *semantically* (an asset's
6//! `prov:wasDerivedFrom` / `hasProvenance` edges — see [`crate::hypermedia`]).
7//! That is queryable, but the source bytes / licence / verifiable-credential
8//! live *outside* the sealed `.10d`, so a `.10d` copied on its own loses them.
9//! This section carries them **in-envelope**: the immutable source bytes the
10//! asset was derived from, its media type, its **licence** (the never-strip-
11//! context field), and an optional **verifiable credential** attesting the
12//! chain — all under the `.10d`'s own section-table CRC-32C.
13//!
14//! **Validate-before-use.** [`validate_provenance`] is the gate a consumer runs
15//! before trusting the asset as citable: the carried source bytes must hash to
16//! the declared `source_digest` (self-authenticating — the bytes really are the
17//! attested source), and a licence must be present (context was not stripped).
18//! The renderer's governance path already keys "citable" off the presence of a
19//! provenance section (`render/portal/mod.rs` sets `has_attestation`); this
20//! section makes that attestation real and checkable rather than merely
21//! reserved.
22//!
23//! **Layout:** a 32-byte [`ProvenanceMiniHeader`] (magic + version + flags +
24//! `source_digest` + field lengths) followed by the concatenated fields —
25//! `[source_bytes][source_media_type utf8][licence utf8][vc bytes]`. The
26//! mini-header is `repr(C)`, naturally aligned, no implicit padding. Two
27//! encodes of the same sidecar are byte-identical; the section-table CRC-32C
28//! catches a flipped bit.
29
30use bytemuck::{bytes_of, pod_read_unaligned, Pod, Zeroable};
31
32use crate::container_10d::crc32c::crc32c;
33
34/// Section payload mini-header size in bytes.
35pub const PROVENANCE_MINI_HEADER_SIZE: usize = 80;
36
37/// Magic tag at the head of a provenance-section payload (`b"PRV1"`, LE).
38pub const PROVENANCE_MAGIC: u32 = u32::from_le_bytes(*b"PRV1");
39
40/// Provenance-section payload version.
41pub const PROVENANCE_SECTION_VERSION: u16 = 2;
42
43/// `flags` bit 0: a verifiable credential is present (`vc_len` must be > 0).
44pub const FLAG_HAS_VC: u16 = 0x0001;
45
46/// Upper bound per variable-length field — bounds a hostile/malformed file.
47/// 16 MiB comfortably holds a source document, its media-type label, a licence
48/// string, and a VC while staying well under the 42 MB Sentinel ceiling.
49pub const MAX_PROVENANCE_FIELD: usize = 16 * 1024 * 1024;
50
51/// The 80-byte ProvenanceSidecar-section mini-header. `repr(C)`, naturally
52/// aligned, no implicit padding.
53///
54/// ```text
55/// offset  size  field
56/// 0       4     magic:u32          (PROVENANCE_MAGIC)
57/// 4       2     version:u16        (PROVENANCE_SECTION_VERSION)
58/// 6       2     flags:u16          (bit 0 = a VC is present)
59/// 8       4     source_digest:u32  (CRC-32C over source_bytes — the gate anchor)
60/// 12      4     reserved_u32       (must be zero) - moves before u64 to align to 16
61/// 16      8     timestamp_epoch_s:u64 (Date of harvest/authoring)
62/// 24      32    version_hash:[u8; 32] (Cryptographic version control hash e.g., SHA256)
63/// 56      4     source_len:u32
64/// 60      4     media_len:u32      (source media-type utf8 length)
65/// 64      4     licence_len:u32
66/// 68      4     vc_len:u32
67/// 72      4     metadata_len:u32   (Schema.org / Dublin Core semantic JSON-LD length)
68/// 76      4     reserved_pad:u32   (padding for 80-byte alignment)
69/// ```
70#[repr(C)]
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Pod, Zeroable)]
72pub struct ProvenanceMiniHeader {
73    pub magic: u32,
74    pub version: u16,
75    pub flags: u16,
76    pub source_digest: u32,
77    pub reserved_u32: u32,
78    pub timestamp_epoch_s: u64,
79    pub version_hash: [u8; 32],
80    pub source_len: u32,
81    pub media_len: u32,
82    pub licence_len: u32,
83    pub vc_len: u32,
84    pub metadata_len: u32,
85    pub reserved_pad: u32,
86}
87
88/// An owned provenance sidecar to bundle into a `.10d` — the source bytes an
89/// asset was derived from, their media type, the licence (required), and an
90/// optional verifiable credential.
91#[derive(Debug, Clone, PartialEq, Eq, Default)]
92pub struct ProvenanceSidecar {
93    /// The immutable original bytes the asset was derived from.
94    pub source_bytes: Vec<u8>,
95    /// The source's media type (e.g. `model/gltf-binary`, `text/markdown`).
96    pub source_media_type: String,
97    /// The licence the source is available under (e.g. `CC-BY-4.0`, `CC0`).
98    /// Required — a provenance record with no licence is a stripped context.
99    pub licence: String,
100    /// An optional verifiable credential (CBOR / JWT bytes) attesting the
101    /// derivation chain. Empty = none.
102    pub vc: Vec<u8>,
103    /// Schema.org / Dublin Core semantic CBOR-LD metadata payload.
104    pub semantic_metadata: Vec<u8>,
105    /// Creation or harvest timestamp (UNIX epoch seconds).
106    pub timestamp_epoch_s: u64,
107    /// Cryptographic version control hash (e.g., SHA256 of the git commit or asset).
108    pub version_hash: [u8; 32],
109}
110
111impl ProvenanceSidecar {
112    pub fn new(
113        source_bytes: impl Into<Vec<u8>>,
114        source_media_type: impl Into<String>,
115        licence: impl Into<String>,
116    ) -> Self {
117        Self {
118            source_bytes: source_bytes.into(),
119            source_media_type: source_media_type.into(),
120            licence: licence.into(),
121            vc: Vec::new(),
122            semantic_metadata: Vec::new(),
123            timestamp_epoch_s: 0,
124            version_hash: [0; 32],
125        }
126    }
127
128    pub fn with_vc(mut self, vc: impl Into<Vec<u8>>) -> Self {
129        self.vc = vc.into();
130        self
131    }
132
133    pub fn with_metadata(
134        mut self,
135        metadata: impl Into<Vec<u8>>,
136        timestamp: u64,
137        hash: [u8; 32],
138    ) -> Self {
139        self.semantic_metadata = metadata.into();
140        self.timestamp_epoch_s = timestamp;
141        self.version_hash = hash;
142        self
143    }
144
145    /// The CRC-32C digest of the source bytes — the value stored in the header
146    /// and re-checked by [`validate_provenance`].
147    #[inline]
148    pub fn source_digest(&self) -> u32 {
149        crc32c(&self.source_bytes)
150    }
151}
152
153/// Provenance-section read/write/validate error.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum ProvenanceSectionError {
156    /// The payload is too short for the mini-header.
157    PayloadTooShort { got: usize, need: usize },
158    /// The payload magic is not [`PROVENANCE_MAGIC`].
159    BadMagic { got: u32 },
160    /// The payload version is not [`PROVENANCE_SECTION_VERSION`].
161    UnsupportedVersion { got: u16 },
162    /// The mini-header `reserved_u32` is non-zero.
163    NonZeroReserved,
164    /// Unknown flags bit set (only bit 0 is defined in v1).
165    UnknownFlags { got: u16 },
166    /// A variable-length field exceeds [`MAX_PROVENANCE_FIELD`].
167    FieldTooLarge {
168        field: &'static str,
169        got: usize,
170        max: usize,
171    },
172    /// The payload is too short for the declared field lengths.
173    PayloadTruncated { expected: usize, got: usize },
174    /// The output buffer is too small.
175    OutputBufferTooSmall { needed: usize, have: usize },
176    /// The `FLAG_HAS_VC` bit and `vc_len` disagree.
177    VcFlagInconsistent { has_vc: bool, vc_len: u32 },
178    /// A field declared as utf8 (media type / licence) is not valid utf8.
179    NonUtf8 { field: &'static str },
180    /// Validate gate: the carried source bytes do not hash to the declared
181    /// `source_digest` — the sidecar's source is not authentic to its claim.
182    SourceDigestMismatch { expected: u32, got: u32 },
183    /// Validate gate: no licence is present (context was stripped).
184    MissingLicence,
185}
186
187impl std::fmt::Display for ProvenanceSectionError {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            Self::PayloadTooShort { got, need } => {
191                write!(f, "10d PRV payload too short: got {got}, need {need}")
192            }
193            Self::BadMagic { got } => write!(
194                f,
195                "10d PRV bad magic {got:#010x} (expected {PROVENANCE_MAGIC:#010x})"
196            ),
197            Self::UnsupportedVersion { got } => write!(f, "10d PRV unsupported version {got}"),
198            Self::NonZeroReserved => write!(f, "10d PRV non-zero reserved_u32"),
199            Self::UnknownFlags { got } => write!(
200                f,
201                "10d PRV unknown flags bits {got:#06x} (only bit 0 defined in v1)"
202            ),
203            Self::FieldTooLarge { field, got, max } => {
204                write!(f, "10d PRV field {field:?} too large: {got} > {max}")
205            }
206            Self::PayloadTruncated { expected, got } => write!(
207                f,
208                "10d PRV payload truncated: expected {expected}, got {got}"
209            ),
210            Self::OutputBufferTooSmall { needed, have } => write!(
211                f,
212                "10d PRV output buffer too small: need {needed}, have {have}"
213            ),
214            Self::VcFlagInconsistent { has_vc, vc_len } => write!(
215                f,
216                "10d PRV vc flag inconsistent: has_vc={has_vc}, vc_len={vc_len}"
217            ),
218            Self::NonUtf8 { field } => write!(f, "10d PRV field {field:?} is not valid utf8"),
219            Self::SourceDigestMismatch { expected, got } => write!(
220                f,
221                "10d PRV source-digest mismatch: expected {expected:#010x}, got {got:#010x}"
222            ),
223            Self::MissingLicence => write!(f, "10d PRV missing licence (context stripped)"),
224        }
225    }
226}
227
228impl std::error::Error for ProvenanceSectionError {}
229
230/// Encoded payload length in bytes for a sidecar (mini-header + fields).
231#[inline]
232pub fn encoded_len(s: &ProvenanceSidecar) -> usize {
233    PROVENANCE_MINI_HEADER_SIZE
234        + s.source_bytes.len()
235        + s.source_media_type.len()
236        + s.licence.len()
237        + s.vc.len()
238        + s.semantic_metadata.len()
239}
240
241/// Encode a provenance sidecar into a caller-supplied buffer. Returns the
242/// number of bytes written. Deterministic (two encodes are byte-identical).
243pub fn encode_provenance_section(
244    s: &ProvenanceSidecar,
245    out: &mut [u8],
246) -> Result<usize, ProvenanceSectionError> {
247    let check = |field, len: usize| -> Result<(), ProvenanceSectionError> {
248        if len > MAX_PROVENANCE_FIELD {
249            Err(ProvenanceSectionError::FieldTooLarge {
250                field,
251                got: len,
252                max: MAX_PROVENANCE_FIELD,
253            })
254        } else {
255            Ok(())
256        }
257    };
258    check("source", s.source_bytes.len())?;
259    check("media_type", s.source_media_type.len())?;
260    check("licence", s.licence.len())?;
261    check("vc", s.vc.len())?;
262    check("semantic_metadata", s.semantic_metadata.len())?;
263
264    let total = encoded_len(s);
265    if out.len() < total {
266        return Err(ProvenanceSectionError::OutputBufferTooSmall {
267            needed: total,
268            have: out.len(),
269        });
270    }
271
272    let flags = if s.vc.is_empty() { 0 } else { FLAG_HAS_VC };
273    let header = ProvenanceMiniHeader {
274        magic: PROVENANCE_MAGIC,
275        version: PROVENANCE_SECTION_VERSION,
276        flags,
277        source_digest: s.source_digest(),
278        reserved_u32: 0,
279        timestamp_epoch_s: s.timestamp_epoch_s,
280        version_hash: s.version_hash,
281        source_len: s.source_bytes.len() as u32,
282        media_len: s.source_media_type.len() as u32,
283        licence_len: s.licence.len() as u32,
284        vc_len: s.vc.len() as u32,
285        metadata_len: s.semantic_metadata.len() as u32,
286        reserved_pad: 0,
287    };
288
289    let mut cursor = 0;
290    out[cursor..cursor + PROVENANCE_MINI_HEADER_SIZE].copy_from_slice(bytes_of(&header));
291    cursor += PROVENANCE_MINI_HEADER_SIZE;
292    out[cursor..cursor + s.source_bytes.len()].copy_from_slice(&s.source_bytes);
293    cursor += s.source_bytes.len();
294    out[cursor..cursor + s.source_media_type.len()].copy_from_slice(s.source_media_type.as_bytes());
295    cursor += s.source_media_type.len();
296    out[cursor..cursor + s.licence.len()].copy_from_slice(s.licence.as_bytes());
297    cursor += s.licence.len();
298    out[cursor..cursor + s.vc.len()].copy_from_slice(&s.vc);
299    cursor += s.vc.len();
300    out[cursor..cursor + s.semantic_metadata.len()].copy_from_slice(&s.semantic_metadata);
301    cursor += s.semantic_metadata.len();
302
303    debug_assert_eq!(cursor, total);
304    Ok(total)
305}
306
307/// A zero-copy read view over a decoded provenance-section payload.
308#[derive(Debug, Clone, Copy)]
309pub struct ProvenanceSidecarView<'a> {
310    header: ProvenanceMiniHeader,
311    source_bytes: &'a [u8],
312    source_media_type: &'a str,
313    licence: &'a str,
314    vc: &'a [u8],
315    semantic_metadata: &'a [u8],
316}
317
318impl<'a> ProvenanceSidecarView<'a> {
319    /// The immutable source bytes the asset was derived from.
320    #[inline]
321    pub fn source_bytes(&self) -> &'a [u8] {
322        self.source_bytes
323    }
324    /// The source's media type.
325    #[inline]
326    pub fn source_media_type(&self) -> &'a str {
327        self.source_media_type
328    }
329    /// The licence the source is available under.
330    #[inline]
331    pub fn licence(&self) -> &'a str {
332        self.licence
333    }
334    /// The attached verifiable credential, if any.
335    #[inline]
336    pub fn vc(&self) -> Option<&'a [u8]> {
337        if self.vc.is_empty() {
338            None
339        } else {
340            Some(self.vc)
341        }
342    }
343    /// Schema.org / Dublin Core semantic CBOR-LD metadata payload.
344    #[inline]
345    pub fn semantic_metadata(&self) -> &'a [u8] {
346        self.semantic_metadata
347    }
348    /// Harvest or creation timestamp.
349    #[inline]
350    pub fn timestamp_epoch_s(&self) -> u64 {
351        self.header.timestamp_epoch_s
352    }
353    /// Cryptographic version control hash.
354    #[inline]
355    pub fn version_hash(&self) -> &[u8; 32] {
356        &self.header.version_hash
357    }
358    /// The declared source digest (CRC-32C over the source bytes).
359    #[inline]
360    pub fn source_digest(&self) -> u32 {
361        self.header.source_digest
362    }
363}
364
365/// Parse the mini-header and slice the fields out of a provenance-section
366/// payload (zero-copy). Validates magic, version, reserved, flag consistency,
367/// field bounds, and utf8 — but does **not** run the trust gate; call
368/// [`validate_provenance`] before using the sidecar as an attestation.
369pub fn decode_provenance_section(
370    payload: &[u8],
371) -> Result<ProvenanceSidecarView<'_>, ProvenanceSectionError> {
372    if payload.len() < PROVENANCE_MINI_HEADER_SIZE {
373        return Err(ProvenanceSectionError::PayloadTooShort {
374            got: payload.len(),
375            need: PROVENANCE_MINI_HEADER_SIZE,
376        });
377    }
378    // `pod_read_unaligned` (not `from_bytes`): a section payload — or a raw
379    // test buffer — is not guaranteed aligned to the header's 4-byte alignment,
380    // and `from_bytes` panics on misalignment. This copies the 32 header bytes
381    // into an aligned value.
382    let header: ProvenanceMiniHeader = pod_read_unaligned(&payload[..PROVENANCE_MINI_HEADER_SIZE]);
383    if header.magic != PROVENANCE_MAGIC {
384        return Err(ProvenanceSectionError::BadMagic { got: header.magic });
385    }
386    if header.version != PROVENANCE_SECTION_VERSION {
387        return Err(ProvenanceSectionError::UnsupportedVersion {
388            got: header.version,
389        });
390    }
391    if header.reserved_u32 != 0 {
392        return Err(ProvenanceSectionError::NonZeroReserved);
393    }
394    if header.flags & !FLAG_HAS_VC != 0 {
395        return Err(ProvenanceSectionError::UnknownFlags { got: header.flags });
396    }
397    let has_vc = header.flags & FLAG_HAS_VC != 0;
398    if has_vc != (header.vc_len > 0) {
399        return Err(ProvenanceSectionError::VcFlagInconsistent {
400            has_vc,
401            vc_len: header.vc_len,
402        });
403    }
404
405    let source_len = header.source_len as usize;
406    let media_len = header.media_len as usize;
407    let licence_len = header.licence_len as usize;
408    let vc_len = header.vc_len as usize;
409    let metadata_len = header.metadata_len as usize;
410    for (field, len) in [
411        ("source", source_len),
412        ("media_type", media_len),
413        ("licence", licence_len),
414        ("vc", vc_len),
415        ("semantic_metadata", metadata_len),
416    ] {
417        if len > MAX_PROVENANCE_FIELD {
418            return Err(ProvenanceSectionError::FieldTooLarge {
419                field,
420                got: len,
421                max: MAX_PROVENANCE_FIELD,
422            });
423        }
424    }
425
426    let expected =
427        PROVENANCE_MINI_HEADER_SIZE + source_len + media_len + licence_len + vc_len + metadata_len;
428    if payload.len() < expected {
429        return Err(ProvenanceSectionError::PayloadTruncated {
430            expected,
431            got: payload.len(),
432        });
433    }
434
435    let mut cursor = PROVENANCE_MINI_HEADER_SIZE;
436    let source_bytes = &payload[cursor..cursor + source_len];
437    cursor += source_len;
438    let media_raw = &payload[cursor..cursor + media_len];
439    cursor += media_len;
440    let licence_raw = &payload[cursor..cursor + licence_len];
441    cursor += licence_len;
442    let vc = &payload[cursor..cursor + vc_len];
443    cursor += vc_len;
444    let metadata_raw = &payload[cursor..cursor + metadata_len];
445
446    let source_media_type =
447        std::str::from_utf8(media_raw).map_err(|_| ProvenanceSectionError::NonUtf8 {
448            field: "media_type",
449        })?;
450    let licence = std::str::from_utf8(licence_raw)
451        .map_err(|_| ProvenanceSectionError::NonUtf8 { field: "licence" })?;
452    let semantic_metadata = metadata_raw;
453
454    Ok(ProvenanceSidecarView {
455        header,
456        source_bytes,
457        source_media_type,
458        licence,
459        vc,
460        semantic_metadata,
461    })
462}
463
464/// The **validate-before-use gate**: return `Ok` only if the sidecar can be
465/// trusted as the asset's provenance. Two independent checks:
466///
467/// 1. **Self-authenticating source** — the carried `source_bytes` hash to the
468///    declared `source_digest` (the bytes really are the attested source; a
469///    swapped source is rejected).
470/// 2. **Context present** — a non-empty licence (a provenance record with no
471///    licence is a stripped context, not an attestation).
472///
473/// A consumer runs this before treating an asset as citable/attested (mirroring
474/// the renderer's `has_attestation` governance gate).
475pub fn validate_provenance(view: &ProvenanceSidecarView<'_>) -> Result<(), ProvenanceSectionError> {
476    let got = crc32c(view.source_bytes);
477    if got != view.header.source_digest {
478        return Err(ProvenanceSectionError::SourceDigestMismatch {
479            expected: view.header.source_digest,
480            got,
481        });
482    }
483    if view.licence.trim().is_empty() {
484        return Err(ProvenanceSectionError::MissingLicence);
485    }
486    Ok(())
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn sample() -> ProvenanceSidecar {
494        ProvenanceSidecar::new(
495            b"<the original source GLB bytes>".to_vec(),
496            "model/gltf-binary",
497            "CC-BY-4.0",
498        )
499        .with_vc(b"{\"vc\":\"attested\"}".to_vec())
500        .with_metadata(
501            b"\xA2\x68@context\x78\x1Dhttps://schema.org/\x65@type\x67Dataset".to_vec(),
502            1690000000,
503            [0xAA; 32],
504        )
505    }
506
507    #[test]
508    fn round_trips_and_validates() {
509        let s = sample();
510        let mut buf = vec![0u8; encoded_len(&s)];
511        let n = encode_provenance_section(&s, &mut buf).unwrap();
512        assert_eq!(n, buf.len());
513
514        let view = decode_provenance_section(&buf).unwrap();
515        assert_eq!(view.source_bytes(), s.source_bytes.as_slice());
516        assert_eq!(view.source_media_type(), "model/gltf-binary");
517        assert_eq!(view.licence(), "CC-BY-4.0");
518        assert_eq!(view.vc(), Some(s.vc.as_slice()));
519        assert_eq!(
520            view.semantic_metadata(),
521            b"\xA2\x68@context\x78\x1Dhttps://schema.org/\x65@type\x67Dataset"
522        );
523        assert_eq!(view.timestamp_epoch_s(), 1690000000);
524        assert_eq!(view.version_hash(), &[0xAA; 32]);
525        assert_eq!(view.source_digest(), crc32c(&s.source_bytes));
526        // The gate passes for an authentic, licensed sidecar.
527        validate_provenance(&view).unwrap();
528    }
529
530    #[test]
531    fn deterministic_encoding() {
532        let s = sample();
533        let mut a = vec![0u8; encoded_len(&s)];
534        let mut b = vec![0u8; encoded_len(&s)];
535        encode_provenance_section(&s, &mut a).unwrap();
536        encode_provenance_section(&s, &mut b).unwrap();
537        assert_eq!(a, b, "two encodes of the same sidecar are byte-identical");
538    }
539
540    #[test]
541    fn no_vc_clears_the_flag() {
542        let s = ProvenanceSidecar::new(b"src".to_vec(), "text/plain", "CC0");
543        let mut buf = vec![0u8; encoded_len(&s)];
544        encode_provenance_section(&s, &mut buf).unwrap();
545        let view = decode_provenance_section(&buf).unwrap();
546        assert_eq!(view.vc(), None);
547        validate_provenance(&view).unwrap();
548    }
549
550    #[test]
551    fn tampered_source_bytes_fail_the_gate() {
552        let s = sample();
553        let mut buf = vec![0u8; encoded_len(&s)];
554        encode_provenance_section(&s, &mut buf).unwrap();
555        // Flip a byte in the source-bytes region (after the 32-byte header).
556        buf[PROVENANCE_MINI_HEADER_SIZE] ^= 0xFF;
557        let view = decode_provenance_section(&buf).unwrap();
558        assert!(matches!(
559            validate_provenance(&view),
560            Err(ProvenanceSectionError::SourceDigestMismatch { .. })
561        ));
562    }
563
564    #[test]
565    fn a_stripped_licence_fails_the_gate() {
566        let s = ProvenanceSidecar::new(b"src".to_vec(), "text/plain", "");
567        let mut buf = vec![0u8; encoded_len(&s)];
568        encode_provenance_section(&s, &mut buf).unwrap();
569        let view = decode_provenance_section(&buf).unwrap();
570        assert_eq!(
571            validate_provenance(&view),
572            Err(ProvenanceSectionError::MissingLicence)
573        );
574    }
575
576    #[test]
577    fn round_trips_through_the_real_container_section_table() {
578        use crate::container_10d::header::Container10dHeader;
579        use crate::container_10d::section::{
580            encode_container, parse_section_table, AlignmentTier, SectionInput, SectionType,
581        };
582
583        // A provenance sidecar bundled alongside a (stand-in) mesh section.
584        let sidecar = sample();
585        let mut prov_payload = vec![0u8; encoded_len(&sidecar)];
586        encode_provenance_section(&sidecar, &mut prov_payload).unwrap();
587        let mesh_payload = [0xAAu8; 64];
588
589        let inputs = [
590            SectionInput {
591                section_type: SectionType::QuantizedMesh,
592                alignment_tier: AlignmentTier::Word,
593                stride: 0,
594                element_count: 0,
595                payload: &mesh_payload,
596            },
597            SectionInput {
598                // type 7 — accepted by the encoder now that it is implemented.
599                section_type: SectionType::ProvenanceSidecar,
600                alignment_tier: AlignmentTier::Word,
601                stride: 0,
602                element_count: 0,
603                payload: &prov_payload,
604            },
605        ];
606
607        let h = Container10dHeader::proposed();
608        let mut out = vec![0u8; 4096];
609        let n = encode_container(&h, &inputs, &mut out).expect("encode container w/ provenance");
610        let parsed = Container10dHeader::parse(&out[..n]).expect("header parse");
611        let descs = parse_section_table(&out[..n], &parsed).expect("table parse (CRC-checked)");
612
613        // The provenance section is present and readable straight out of the .10d.
614        let prov = descs
615            .iter()
616            .find(|d| d.section_type == SectionType::ProvenanceSidecar as u8)
617            .expect("provenance section in table");
618        let payload = &out[prov.byte_offset as usize..][..prov.byte_length as usize];
619        let view = decode_provenance_section(payload).expect("decode from container");
620        validate_provenance(&view).expect("validate-before-use passes for the bundled sidecar");
621        assert_eq!(view.licence(), "CC-BY-4.0");
622        assert_eq!(view.source_bytes(), sidecar.source_bytes.as_slice());
623    }
624
625    #[test]
626    fn bad_magic_and_short_payload_are_rejected() {
627        assert!(matches!(
628            decode_provenance_section(&[0u8; 8]),
629            Err(ProvenanceSectionError::PayloadTooShort { .. })
630        ));
631        let mut buf = [0u8; PROVENANCE_MINI_HEADER_SIZE];
632        // zeroed header ⇒ magic 0 ⇒ BadMagic
633        assert!(matches!(
634            decode_provenance_section(&buf),
635            Err(ProvenanceSectionError::BadMagic { .. })
636        ));
637        // set a good magic but truncated declared source_len
638        let bad = ProvenanceMiniHeader {
639            magic: PROVENANCE_MAGIC,
640            version: PROVENANCE_SECTION_VERSION,
641            flags: 0,
642            source_digest: 0,
643            reserved_u32: 0,
644            timestamp_epoch_s: 0,
645            version_hash: [0; 32],
646            source_len: 100,
647            media_len: 0,
648            licence_len: 0,
649            vc_len: 0,
650            metadata_len: 0,
651            reserved_pad: 0,
652        };
653        buf.copy_from_slice(bytes_of(&bad));
654        assert!(matches!(
655            decode_provenance_section(&buf),
656            Err(ProvenanceSectionError::PayloadTruncated { .. })
657        ));
658    }
659}