1use std::collections::HashMap;
26
27use thiserror::Error;
28
29use crate::artifact_model::{build_bundle_lock, fold_evidence_hash, BundleLock, CellMap};
30use crate::bundle_source::{BundleSource, BundleSourceError};
31use crate::changelog::VersionChangelog;
32use crate::dag::Dag;
33use crate::manifest_model::Manifest;
34use crate::render::LayoutDescriptor;
35use crate::sheet_ir::{build_dag, Cell};
36
37pub const MEMBER_IR: &str = "executable.ir.json";
39pub const MEMBER_MANIFEST: &str = "manifest.json";
41pub const MEMBER_CELL_MAP: &str = "cell_map.json";
43pub const MEMBER_LAYOUT: &str = "layout.json";
45pub const MEMBER_LOCK: &str = "BUNDLE.lock";
47pub const MEMBER_CHANGELOG: &str = "evidence/changelog.json";
49pub const MEMBER_PARSER_EQUIV: &str = "evidence/parser_equivalence.json";
51
52pub const ALLOWED_MEMBERS: &[&str] = &[
58 MEMBER_IR,
59 MEMBER_MANIFEST,
60 MEMBER_CELL_MAP,
61 MEMBER_LAYOUT,
62 MEMBER_LOCK,
63 MEMBER_CHANGELOG,
64 MEMBER_PARSER_EQUIV,
65];
66
67pub const EVIDENCE_FOLD_MEMBERS: &[&str] = &[
72 MEMBER_CELL_MAP,
73 MEMBER_CHANGELOG,
74 MEMBER_PARSER_EQUIV,
75 MEMBER_LAYOUT,
76];
77
78#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct WorkbookBundle {
85 pub ir: HashMap<String, Cell>,
87 pub dag: Dag,
89 pub manifest: Manifest,
91 pub cell_map: CellMap,
93 pub layout: LayoutDescriptor,
95 pub changelog: VersionChangelog,
97 pub stamp: BundleLock,
99}
100
101#[derive(Debug, Error)]
106#[non_exhaustive]
107pub enum BundleLoadError {
108 #[error("bundle source error reading {member}: {detail}")]
110 Source {
111 member: String,
113 detail: String,
115 },
116
117 #[error("failed to parse bundle member {what}: {detail}")]
119 Parse {
120 what: String,
122 detail: String,
124 },
125
126 #[error(
130 "bundle integrity mismatch: expected combined {expected}, recomputed {recomputed} \
131 (expected evidence {expected_evidence}, recomputed {recomputed_evidence})"
132 )]
133 IntegrityMismatch {
134 expected: String,
136 recomputed: String,
138 expected_evidence: String,
140 recomputed_evidence: String,
142 },
143
144 #[error(
147 "bundle stamp mismatch on {field}: lock has {lock_value:?} but {member} has {member_value:?}"
148 )]
149 StampMismatch {
150 field: &'static str,
152 lock_value: String,
154 member_value: String,
156 member: &'static str,
158 },
159
160 #[error("unexpected bundle member (not in the frozen allow-set): {member}")]
162 UnexpectedMember {
163 member: String,
165 },
166}
167
168fn read_member(source: &dyn BundleSource, member: &str) -> Result<Vec<u8>, BundleLoadError> {
170 source.read_artifact(member).map_err(|e| match e {
171 BundleSourceError::NotFound { member } => BundleLoadError::Source {
172 member: member.clone(),
173 detail: format!("member not found: {member}"),
174 },
175 BundleSourceError::Io(detail) => BundleLoadError::Source {
176 member: member.to_string(),
177 detail,
178 },
179 })
180}
181
182fn parse_member<T: serde::de::DeserializeOwned>(
184 bytes: &[u8],
185 what: &str,
186) -> Result<T, BundleLoadError> {
187 serde_json::from_slice(bytes).map_err(|e| BundleLoadError::Parse {
188 what: what.to_string(),
189 detail: e.to_string(),
190 })
191}
192
193fn recompute_evidence_hash(source: &dyn BundleSource) -> Result<String, BundleLoadError> {
197 let mut bodies: Vec<(&str, Vec<u8>)> = Vec::with_capacity(EVIDENCE_FOLD_MEMBERS.len());
198 for member in EVIDENCE_FOLD_MEMBERS {
199 bodies.push((member, read_member(source, member)?));
200 }
201 let members: Vec<(&str, &[u8])> = bodies.iter().map(|(p, b)| (*p, b.as_slice())).collect();
202 Ok(fold_evidence_hash(&members))
203}
204
205fn enforce_member_allow_set(source: &dyn BundleSource) -> Result<(), BundleLoadError> {
208 let members = source
209 .list_artifacts()
210 .map_err(|e| BundleLoadError::Source {
211 member: "<list_artifacts>".to_string(),
212 detail: match e {
213 BundleSourceError::Io(d) => d,
214 BundleSourceError::NotFound { member } => format!("not found: {member}"),
215 },
216 })?;
217 for member in &members {
218 if !ALLOWED_MEMBERS.contains(&member.as_str()) {
219 return Err(BundleLoadError::UnexpectedMember {
220 member: member.clone(),
221 });
222 }
223 }
224 Ok(())
225}
226
227fn member_utf8<'a>(bytes: &'a [u8], what: &str) -> Result<&'a str, BundleLoadError> {
230 std::str::from_utf8(bytes).map_err(|e| BundleLoadError::Parse {
231 what: what.to_string(),
232 detail: e.to_string(),
233 })
234}
235
236fn verify_integrity(
242 source: &dyn BundleSource,
243) -> Result<(BundleLock, Vec<u8>, Vec<u8>), BundleLoadError> {
244 let lock_bytes = read_member(source, MEMBER_LOCK)?;
245 let lock: BundleLock = parse_member(&lock_bytes, MEMBER_LOCK)?;
246
247 let ir_bytes = read_member(source, MEMBER_IR)?;
248 let manifest_bytes = read_member(source, MEMBER_MANIFEST)?;
249
250 let evidence_hash = recompute_evidence_hash(source)?;
251 let ir_json = member_utf8(&ir_bytes, MEMBER_IR)?;
252 let manifest_json = member_utf8(&manifest_bytes, MEMBER_MANIFEST)?;
253 let recomputed = build_bundle_lock(
254 &lock.bundle_id,
255 &lock.version,
256 lock.workbook_hash.clone(),
257 ir_json,
258 manifest_json,
259 &evidence_hash,
260 );
261 if recomputed.artifacts != lock.artifacts || recomputed.combined != lock.combined {
262 return Err(BundleLoadError::IntegrityMismatch {
263 expected: lock.combined,
264 recomputed: recomputed.combined,
265 expected_evidence: lock.artifacts.evidence,
266 recomputed_evidence: evidence_hash,
267 });
268 }
269
270 Ok((lock, ir_bytes, manifest_bytes))
271}
272
273struct ParsedMembers {
277 ir: HashMap<String, Cell>,
278 manifest: Manifest,
279 cell_map: CellMap,
280 layout: LayoutDescriptor,
281 changelog: VersionChangelog,
282}
283
284fn parse_members(
288 source: &dyn BundleSource,
289 ir_bytes: &[u8],
290 manifest_bytes: &[u8],
291) -> Result<ParsedMembers, BundleLoadError> {
292 let ir: HashMap<String, Cell> = parse_member(ir_bytes, MEMBER_IR)?;
293 let manifest: Manifest = parse_member(manifest_bytes, MEMBER_MANIFEST)?;
294 let cell_map: CellMap = parse_member(&read_member(source, MEMBER_CELL_MAP)?, MEMBER_CELL_MAP)?;
295 let layout: LayoutDescriptor =
296 parse_member(&read_member(source, MEMBER_LAYOUT)?, MEMBER_LAYOUT)?;
297 let changelog: VersionChangelog =
298 parse_member(&read_member(source, MEMBER_CHANGELOG)?, MEMBER_CHANGELOG)?;
299 Ok(ParsedMembers {
300 ir,
301 manifest,
302 cell_map,
303 layout,
304 changelog,
305 })
306}
307
308fn verify_stamp_binding(
314 lock: &BundleLock,
315 manifest: &Manifest,
316 layout: &LayoutDescriptor,
317 changelog: &VersionChangelog,
318) -> Result<(), BundleLoadError> {
319 let Some(layout_hash) = layout.source_workbook_hash.as_deref() else {
324 return Err(BundleLoadError::StampMismatch {
325 field: "workbook_hash",
326 lock_value: lock.workbook_hash.clone(),
327 member_value: "<absent>".to_string(),
328 member: "layout.json (source_workbook_hash)",
329 });
330 };
331 if layout_hash != lock.workbook_hash {
332 return Err(BundleLoadError::StampMismatch {
333 field: "workbook_hash",
334 lock_value: lock.workbook_hash.clone(),
335 member_value: layout_hash.to_string(),
336 member: "layout.json (source_workbook_hash)",
337 });
338 }
339 if manifest.workflow != lock.bundle_id {
340 return Err(BundleLoadError::StampMismatch {
341 field: "bundle_id",
342 lock_value: lock.bundle_id.clone(),
343 member_value: manifest.workflow.clone(),
344 member: "manifest.json (workflow)",
345 });
346 }
347 if changelog.to_version != lock.version {
348 return Err(BundleLoadError::StampMismatch {
349 field: "version",
350 lock_value: lock.version.clone(),
351 member_value: changelog.to_version.clone(),
352 member: "evidence/changelog.json (to_version)",
353 });
354 }
355 Ok(())
356}
357
358pub fn load(source: &dyn BundleSource) -> Result<WorkbookBundle, BundleLoadError> {
372 enforce_member_allow_set(source)?;
374
375 let (lock, ir_bytes, manifest_bytes) = verify_integrity(source)?;
378
379 let members = parse_members(source, &ir_bytes, &manifest_bytes)?;
381
382 verify_stamp_binding(
384 &lock,
385 &members.manifest,
386 &members.layout,
387 &members.changelog,
388 )?;
389
390 let dag = build_dag(&members.ir);
392
393 Ok(WorkbookBundle {
394 ir: members.ir,
395 dag,
396 manifest: members.manifest,
397 cell_map: members.cell_map,
398 layout: members.layout,
399 changelog: members.changelog,
400 stamp: lock,
401 })
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::artifact_model::{sha256_hex, CellEntry, Tool};
408 use crate::manifest_model::Manifest;
409 use crate::render::LayoutDescriptor;
410
411 struct MapSource {
414 members: HashMap<String, Vec<u8>>,
415 }
416
417 impl BundleSource for MapSource {
418 fn read_artifact(&self, name: &str) -> Result<Vec<u8>, BundleSourceError> {
419 self.members
420 .get(name)
421 .cloned()
422 .ok_or_else(|| BundleSourceError::NotFound {
423 member: name.to_string(),
424 })
425 }
426 fn list_artifacts(&self) -> Result<Vec<String>, BundleSourceError> {
427 let mut v: Vec<String> = self.members.keys().cloned().collect();
428 v.sort();
429 Ok(v)
430 }
431 }
432
433 fn empty_manifest(workflow: &str) -> Manifest {
434 Manifest {
435 schema_version: 1,
436 workflow: workflow.to_string(),
437 workbook_hash: None,
438 ratified: true,
439 ratified_by: None,
440 ratified_at: None,
441 cells: vec![],
442 loop_block: None,
443 governed_data: vec![],
444 changelog: vec![],
445 capability_calls: vec![],
446 annotations: vec![],
447 }
448 }
449
450 fn sample_layout(hash: Option<&str>) -> LayoutDescriptor {
451 LayoutDescriptor {
452 descriptor_version: crate::render::LAYOUT_DESCRIPTOR_VERSION,
453 source_workbook_hash: hash.map(String::from),
454 sheets: vec![],
455 }
456 }
457
458 fn sample_changelog(to_version: &str) -> VersionChangelog {
459 VersionChangelog {
460 from_version: "0.9.0".to_string(),
461 to_version: to_version.to_string(),
462 deltas: vec![],
463 summary: "test".to_string(),
464 }
465 }
466
467 fn sample_cell_map() -> CellMap {
468 CellMap {
469 inputs: vec![CellEntry {
470 json_key: "rate".to_string(),
471 seed_coord: "1_Inputs!E6".to_string(),
472 unit: Some("ratio".to_string()),
473 }],
474 tools: vec![Tool {
475 name: "Calculate".to_string(),
476 description: None,
477 input_keys: vec!["rate".to_string()],
478 outputs: vec![CellEntry {
479 json_key: "total".to_string(),
480 seed_coord: "7_Out!C11".to_string(),
481 unit: Some("GBP".to_string()),
482 }],
483 oracle: std::collections::BTreeMap::new(),
484 }],
485 }
486 }
487
488 fn golden_with(
493 lock_version: &str,
494 changelog_version: &str,
495 lock_workbook_hash: String,
496 layout_anchor: Option<&str>,
497 ) -> MapSource {
498 let bundle_id = "tax-calc";
499
500 let ir: HashMap<String, Cell> = HashMap::new();
501 let ir_json = serde_json::to_string(&ir).unwrap();
502 let manifest = empty_manifest(bundle_id);
503 let manifest_json = serde_json::to_string(&manifest).unwrap();
504 let cell_map_json = serde_json::to_string(&sample_cell_map()).unwrap();
505 let layout_json = serde_json::to_string(&sample_layout(layout_anchor)).unwrap();
506 let changelog_json = serde_json::to_string(&sample_changelog(changelog_version)).unwrap();
507 let parser_equiv_json = r#"{"equivalent":true}"#.to_string();
508
509 let evidence_hash = fold_evidence_hash(&[
510 (MEMBER_CELL_MAP, cell_map_json.as_bytes()),
511 (MEMBER_LAYOUT, layout_json.as_bytes()),
512 (MEMBER_CHANGELOG, changelog_json.as_bytes()),
513 (MEMBER_PARSER_EQUIV, parser_equiv_json.as_bytes()),
514 ]);
515
516 let lock = build_bundle_lock(
517 bundle_id,
518 lock_version,
519 lock_workbook_hash,
520 &ir_json,
521 &manifest_json,
522 &evidence_hash,
523 );
524 let lock_json = serde_json::to_string(&lock).unwrap();
525
526 let mut members = HashMap::new();
527 members.insert(MEMBER_IR.to_string(), ir_json.into_bytes());
528 members.insert(MEMBER_MANIFEST.to_string(), manifest_json.into_bytes());
529 members.insert(MEMBER_CELL_MAP.to_string(), cell_map_json.into_bytes());
530 members.insert(MEMBER_LAYOUT.to_string(), layout_json.into_bytes());
531 members.insert(MEMBER_CHANGELOG.to_string(), changelog_json.into_bytes());
532 members.insert(
533 MEMBER_PARSER_EQUIV.to_string(),
534 parser_equiv_json.into_bytes(),
535 );
536 members.insert(MEMBER_LOCK.to_string(), lock_json.into_bytes());
537 MapSource { members }
538 }
539
540 fn golden_with_versions(lock_version: &str, changelog_version: &str) -> MapSource {
543 let workbook_hash = sha256_hex(b"source-workbook-bytes");
544 golden_with(
545 lock_version,
546 changelog_version,
547 workbook_hash.clone(),
548 Some(&workbook_hash),
549 )
550 }
551
552 fn valid_golden() -> MapSource {
554 golden_with_versions("1.0.0", "1.0.0")
555 }
556
557 fn golden_with_absent_anchor_and_empty_lock_hash() -> MapSource {
563 golden_with("1.0.0", "1.0.0", String::new(), None)
564 }
565
566 #[test]
567 fn load_valid_golden_returns_populated_bundle() {
568 let source = valid_golden();
569 let bundle = load(&source).expect("valid golden loads");
570 assert_eq!(bundle.stamp.bundle_id, "tax-calc");
571 assert_eq!(bundle.stamp.version, "1.0.0");
572 let output_count: usize = bundle.cell_map.tools.iter().map(|t| t.outputs.len()).sum();
573 assert_eq!(output_count, 1);
574 assert_eq!(bundle.changelog.to_version, "1.0.0");
575 assert_eq!(bundle.manifest.workflow, "tax-calc");
576 }
577
578 #[test]
579 fn byte_flip_returns_integrity_mismatch() {
580 let mut source = valid_golden();
581 source.members.insert(
583 MEMBER_MANIFEST.to_string(),
584 br#"{"tampered":true}"#.to_vec(),
585 );
586 match load(&source) {
587 Err(BundleLoadError::IntegrityMismatch {
588 expected,
589 recomputed,
590 ..
591 }) => {
592 assert_ne!(expected, recomputed, "diagnostic carries found-vs-expected");
593 },
594 other => panic!("expected IntegrityMismatch, got {other:?}"),
595 }
596 }
597
598 #[test]
599 fn version_desync_returns_stamp_mismatch() {
600 let source = golden_with_versions("1.0.0", "1.1.0");
604
605 match load(&source) {
606 Err(BundleLoadError::StampMismatch { field, .. }) => {
607 assert_eq!(field, "version");
608 },
609 other => panic!("expected StampMismatch on version, got {other:?}"),
610 }
611 }
612
613 #[test]
614 fn absent_layout_anchor_with_empty_lock_hash_fails_closed() {
615 let source = golden_with_absent_anchor_and_empty_lock_hash();
619 match load(&source) {
620 Err(BundleLoadError::StampMismatch {
621 field,
622 member_value,
623 ..
624 }) => {
625 assert_eq!(field, "workbook_hash");
626 assert_eq!(
627 member_value, "<absent>",
628 "an absent anchor must be reported as <absent>, never defaulted to \"\""
629 );
630 },
631 other => panic!("expected StampMismatch <absent> on workbook_hash, got {other:?}"),
632 }
633 }
634
635 #[test]
636 fn malformed_member_returns_parse_not_panic() {
637 let mut source = valid_golden();
638 source
640 .members
641 .insert(MEMBER_LOCK.to_string(), b"{ not valid json".to_vec());
642 match load(&source) {
643 Err(BundleLoadError::Parse { what, .. }) => {
644 assert_eq!(what, MEMBER_LOCK);
645 },
646 other => panic!("expected Parse, got {other:?}"),
647 }
648 }
649
650 #[test]
651 fn unexpected_extra_member_fails_closed() {
652 let mut source = valid_golden();
653 source
654 .members
655 .insert("evidence/sneaky.json".to_string(), b"{}".to_vec());
656 match load(&source) {
657 Err(BundleLoadError::UnexpectedMember { member }) => {
658 assert_eq!(member, "evidence/sneaky.json");
659 },
660 other => panic!("expected UnexpectedMember, got {other:?}"),
661 }
662 }
663
664 #[test]
665 fn evidence_fold_members_const_is_sorted() {
666 assert!(
669 EVIDENCE_FOLD_MEMBERS.windows(2).all(|w| w[0] < w[1]),
670 "EVIDENCE_FOLD_MEMBERS must be declared in sorted relative-path order"
671 );
672 }
673
674 #[test]
675 fn recompute_evidence_hash_equals_lock_evidence_for_valid_golden() {
676 let source = valid_golden();
679 let lock: BundleLock =
680 parse_member(&source.read_artifact(MEMBER_LOCK).unwrap(), MEMBER_LOCK).unwrap();
681 let recomputed = recompute_evidence_hash(&source).unwrap();
682 assert_eq!(
683 recomputed, lock.artifacts.evidence,
684 "loader and generator must fold the identical evidence member set"
685 );
686 }
687}