1use bytemuck::{bytes_of, pod_read_unaligned, Pod, Zeroable};
31
32use crate::container_10d::crc32c::crc32c;
33
34pub const PROVENANCE_MINI_HEADER_SIZE: usize = 80;
36
37pub const PROVENANCE_MAGIC: u32 = u32::from_le_bytes(*b"PRV1");
39
40pub const PROVENANCE_SECTION_VERSION: u16 = 2;
42
43pub const FLAG_HAS_VC: u16 = 0x0001;
45
46pub const MAX_PROVENANCE_FIELD: usize = 16 * 1024 * 1024;
50
51#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
92pub struct ProvenanceSidecar {
93 pub source_bytes: Vec<u8>,
95 pub source_media_type: String,
97 pub licence: String,
100 pub vc: Vec<u8>,
103 pub semantic_metadata: Vec<u8>,
105 pub timestamp_epoch_s: u64,
107 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 #[inline]
148 pub fn source_digest(&self) -> u32 {
149 crc32c(&self.source_bytes)
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum ProvenanceSectionError {
156 PayloadTooShort { got: usize, need: usize },
158 BadMagic { got: u32 },
160 UnsupportedVersion { got: u16 },
162 NonZeroReserved,
164 UnknownFlags { got: u16 },
166 FieldTooLarge {
168 field: &'static str,
169 got: usize,
170 max: usize,
171 },
172 PayloadTruncated { expected: usize, got: usize },
174 OutputBufferTooSmall { needed: usize, have: usize },
176 VcFlagInconsistent { has_vc: bool, vc_len: u32 },
178 NonUtf8 { field: &'static str },
180 SourceDigestMismatch { expected: u32, got: u32 },
183 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#[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
241pub 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#[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 #[inline]
321 pub fn source_bytes(&self) -> &'a [u8] {
322 self.source_bytes
323 }
324 #[inline]
326 pub fn source_media_type(&self) -> &'a str {
327 self.source_media_type
328 }
329 #[inline]
331 pub fn licence(&self) -> &'a str {
332 self.licence
333 }
334 #[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 #[inline]
345 pub fn semantic_metadata(&self) -> &'a [u8] {
346 self.semantic_metadata
347 }
348 #[inline]
350 pub fn timestamp_epoch_s(&self) -> u64 {
351 self.header.timestamp_epoch_s
352 }
353 #[inline]
355 pub fn version_hash(&self) -> &[u8; 32] {
356 &self.header.version_hash
357 }
358 #[inline]
360 pub fn source_digest(&self) -> u32 {
361 self.header.source_digest
362 }
363}
364
365pub 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 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
464pub 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 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 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 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 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 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 assert!(matches!(
634 decode_provenance_section(&buf),
635 Err(ProvenanceSectionError::BadMagic { .. })
636 ));
637 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}