use std::collections::HashMap;
use thiserror::Error;
use crate::artifact_model::{build_bundle_lock, fold_evidence_hash, BundleLock, CellMap};
use crate::bundle_source::{BundleSource, BundleSourceError};
use crate::changelog::VersionChangelog;
use crate::dag::Dag;
use crate::manifest_model::Manifest;
use crate::render::LayoutDescriptor;
use crate::sheet_ir::{build_dag, Cell};
pub const MEMBER_IR: &str = "executable.ir.json";
pub const MEMBER_MANIFEST: &str = "manifest.json";
pub const MEMBER_CELL_MAP: &str = "cell_map.json";
pub const MEMBER_LAYOUT: &str = "layout.json";
pub const MEMBER_LOCK: &str = "BUNDLE.lock";
pub const MEMBER_CHANGELOG: &str = "evidence/changelog.json";
pub const MEMBER_PARSER_EQUIV: &str = "evidence/parser_equivalence.json";
pub const ALLOWED_MEMBERS: &[&str] = &[
MEMBER_IR,
MEMBER_MANIFEST,
MEMBER_CELL_MAP,
MEMBER_LAYOUT,
MEMBER_LOCK,
MEMBER_CHANGELOG,
MEMBER_PARSER_EQUIV,
];
pub const EVIDENCE_FOLD_MEMBERS: &[&str] = &[
MEMBER_CELL_MAP,
MEMBER_CHANGELOG,
MEMBER_PARSER_EQUIV,
MEMBER_LAYOUT,
];
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorkbookBundle {
pub ir: HashMap<String, Cell>,
pub dag: Dag,
pub manifest: Manifest,
pub cell_map: CellMap,
pub layout: LayoutDescriptor,
pub changelog: VersionChangelog,
pub stamp: BundleLock,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BundleLoadError {
#[error("bundle source error reading {member}: {detail}")]
Source {
member: String,
detail: String,
},
#[error("failed to parse bundle member {what}: {detail}")]
Parse {
what: String,
detail: String,
},
#[error(
"bundle integrity mismatch: expected combined {expected}, recomputed {recomputed} \
(expected evidence {expected_evidence}, recomputed {recomputed_evidence})"
)]
IntegrityMismatch {
expected: String,
recomputed: String,
expected_evidence: String,
recomputed_evidence: String,
},
#[error(
"bundle stamp mismatch on {field}: lock has {lock_value:?} but {member} has {member_value:?}"
)]
StampMismatch {
field: &'static str,
lock_value: String,
member_value: String,
member: &'static str,
},
#[error("unexpected bundle member (not in the frozen allow-set): {member}")]
UnexpectedMember {
member: String,
},
}
fn read_member(source: &dyn BundleSource, member: &str) -> Result<Vec<u8>, BundleLoadError> {
source.read_artifact(member).map_err(|e| match e {
BundleSourceError::NotFound { member } => BundleLoadError::Source {
member: member.clone(),
detail: format!("member not found: {member}"),
},
BundleSourceError::Io(detail) => BundleLoadError::Source {
member: member.to_string(),
detail,
},
})
}
fn parse_member<T: serde::de::DeserializeOwned>(
bytes: &[u8],
what: &str,
) -> Result<T, BundleLoadError> {
serde_json::from_slice(bytes).map_err(|e| BundleLoadError::Parse {
what: what.to_string(),
detail: e.to_string(),
})
}
fn recompute_evidence_hash(source: &dyn BundleSource) -> Result<String, BundleLoadError> {
let mut bodies: Vec<(&str, Vec<u8>)> = Vec::with_capacity(EVIDENCE_FOLD_MEMBERS.len());
for member in EVIDENCE_FOLD_MEMBERS {
bodies.push((member, read_member(source, member)?));
}
let members: Vec<(&str, &[u8])> = bodies.iter().map(|(p, b)| (*p, b.as_slice())).collect();
Ok(fold_evidence_hash(&members))
}
fn enforce_member_allow_set(source: &dyn BundleSource) -> Result<(), BundleLoadError> {
let members = source
.list_artifacts()
.map_err(|e| BundleLoadError::Source {
member: "<list_artifacts>".to_string(),
detail: match e {
BundleSourceError::Io(d) => d,
BundleSourceError::NotFound { member } => format!("not found: {member}"),
},
})?;
for member in &members {
if !ALLOWED_MEMBERS.contains(&member.as_str()) {
return Err(BundleLoadError::UnexpectedMember {
member: member.clone(),
});
}
}
Ok(())
}
fn member_utf8<'a>(bytes: &'a [u8], what: &str) -> Result<&'a str, BundleLoadError> {
std::str::from_utf8(bytes).map_err(|e| BundleLoadError::Parse {
what: what.to_string(),
detail: e.to_string(),
})
}
fn verify_integrity(
source: &dyn BundleSource,
) -> Result<(BundleLock, Vec<u8>, Vec<u8>), BundleLoadError> {
let lock_bytes = read_member(source, MEMBER_LOCK)?;
let lock: BundleLock = parse_member(&lock_bytes, MEMBER_LOCK)?;
let ir_bytes = read_member(source, MEMBER_IR)?;
let manifest_bytes = read_member(source, MEMBER_MANIFEST)?;
let evidence_hash = recompute_evidence_hash(source)?;
let ir_json = member_utf8(&ir_bytes, MEMBER_IR)?;
let manifest_json = member_utf8(&manifest_bytes, MEMBER_MANIFEST)?;
let recomputed = build_bundle_lock(
&lock.bundle_id,
&lock.version,
lock.workbook_hash.clone(),
ir_json,
manifest_json,
&evidence_hash,
);
if recomputed.artifacts != lock.artifacts || recomputed.combined != lock.combined {
return Err(BundleLoadError::IntegrityMismatch {
expected: lock.combined,
recomputed: recomputed.combined,
expected_evidence: lock.artifacts.evidence,
recomputed_evidence: evidence_hash,
});
}
Ok((lock, ir_bytes, manifest_bytes))
}
struct ParsedMembers {
ir: HashMap<String, Cell>,
manifest: Manifest,
cell_map: CellMap,
layout: LayoutDescriptor,
changelog: VersionChangelog,
}
fn parse_members(
source: &dyn BundleSource,
ir_bytes: &[u8],
manifest_bytes: &[u8],
) -> Result<ParsedMembers, BundleLoadError> {
let ir: HashMap<String, Cell> = parse_member(ir_bytes, MEMBER_IR)?;
let manifest: Manifest = parse_member(manifest_bytes, MEMBER_MANIFEST)?;
let cell_map: CellMap = parse_member(&read_member(source, MEMBER_CELL_MAP)?, MEMBER_CELL_MAP)?;
let layout: LayoutDescriptor =
parse_member(&read_member(source, MEMBER_LAYOUT)?, MEMBER_LAYOUT)?;
let changelog: VersionChangelog =
parse_member(&read_member(source, MEMBER_CHANGELOG)?, MEMBER_CHANGELOG)?;
Ok(ParsedMembers {
ir,
manifest,
cell_map,
layout,
changelog,
})
}
fn verify_stamp_binding(
lock: &BundleLock,
manifest: &Manifest,
layout: &LayoutDescriptor,
changelog: &VersionChangelog,
) -> Result<(), BundleLoadError> {
let Some(layout_hash) = layout.source_workbook_hash.as_deref() else {
return Err(BundleLoadError::StampMismatch {
field: "workbook_hash",
lock_value: lock.workbook_hash.clone(),
member_value: "<absent>".to_string(),
member: "layout.json (source_workbook_hash)",
});
};
if layout_hash != lock.workbook_hash {
return Err(BundleLoadError::StampMismatch {
field: "workbook_hash",
lock_value: lock.workbook_hash.clone(),
member_value: layout_hash.to_string(),
member: "layout.json (source_workbook_hash)",
});
}
if manifest.workflow != lock.bundle_id {
return Err(BundleLoadError::StampMismatch {
field: "bundle_id",
lock_value: lock.bundle_id.clone(),
member_value: manifest.workflow.clone(),
member: "manifest.json (workflow)",
});
}
if changelog.to_version != lock.version {
return Err(BundleLoadError::StampMismatch {
field: "version",
lock_value: lock.version.clone(),
member_value: changelog.to_version.clone(),
member: "evidence/changelog.json (to_version)",
});
}
Ok(())
}
pub fn load(source: &dyn BundleSource) -> Result<WorkbookBundle, BundleLoadError> {
enforce_member_allow_set(source)?;
let (lock, ir_bytes, manifest_bytes) = verify_integrity(source)?;
let members = parse_members(source, &ir_bytes, &manifest_bytes)?;
verify_stamp_binding(
&lock,
&members.manifest,
&members.layout,
&members.changelog,
)?;
let dag = build_dag(&members.ir);
Ok(WorkbookBundle {
ir: members.ir,
dag,
manifest: members.manifest,
cell_map: members.cell_map,
layout: members.layout,
changelog: members.changelog,
stamp: lock,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::artifact_model::{sha256_hex, CellEntry, Tool};
use crate::manifest_model::Manifest;
use crate::render::LayoutDescriptor;
struct MapSource {
members: HashMap<String, Vec<u8>>,
}
impl BundleSource for MapSource {
fn read_artifact(&self, name: &str) -> Result<Vec<u8>, BundleSourceError> {
self.members
.get(name)
.cloned()
.ok_or_else(|| BundleSourceError::NotFound {
member: name.to_string(),
})
}
fn list_artifacts(&self) -> Result<Vec<String>, BundleSourceError> {
let mut v: Vec<String> = self.members.keys().cloned().collect();
v.sort();
Ok(v)
}
}
fn empty_manifest(workflow: &str) -> Manifest {
Manifest {
schema_version: 1,
workflow: workflow.to_string(),
workbook_hash: None,
ratified: true,
ratified_by: None,
ratified_at: None,
cells: vec![],
loop_block: None,
governed_data: vec![],
changelog: vec![],
capability_calls: vec![],
annotations: vec![],
}
}
fn sample_layout(hash: Option<&str>) -> LayoutDescriptor {
LayoutDescriptor {
descriptor_version: crate::render::LAYOUT_DESCRIPTOR_VERSION,
source_workbook_hash: hash.map(String::from),
sheets: vec![],
}
}
fn sample_changelog(to_version: &str) -> VersionChangelog {
VersionChangelog {
from_version: "0.9.0".to_string(),
to_version: to_version.to_string(),
deltas: vec![],
summary: "test".to_string(),
}
}
fn sample_cell_map() -> CellMap {
CellMap {
inputs: vec![CellEntry {
json_key: "rate".to_string(),
seed_coord: "1_Inputs!E6".to_string(),
unit: Some("ratio".to_string()),
}],
tools: vec![Tool {
name: "Calculate".to_string(),
description: None,
input_keys: vec!["rate".to_string()],
outputs: vec![CellEntry {
json_key: "total".to_string(),
seed_coord: "7_Out!C11".to_string(),
unit: Some("GBP".to_string()),
}],
oracle: std::collections::BTreeMap::new(),
}],
}
}
fn golden_with(
lock_version: &str,
changelog_version: &str,
lock_workbook_hash: String,
layout_anchor: Option<&str>,
) -> MapSource {
let bundle_id = "tax-calc";
let ir: HashMap<String, Cell> = HashMap::new();
let ir_json = serde_json::to_string(&ir).unwrap();
let manifest = empty_manifest(bundle_id);
let manifest_json = serde_json::to_string(&manifest).unwrap();
let cell_map_json = serde_json::to_string(&sample_cell_map()).unwrap();
let layout_json = serde_json::to_string(&sample_layout(layout_anchor)).unwrap();
let changelog_json = serde_json::to_string(&sample_changelog(changelog_version)).unwrap();
let parser_equiv_json = r#"{"equivalent":true}"#.to_string();
let evidence_hash = fold_evidence_hash(&[
(MEMBER_CELL_MAP, cell_map_json.as_bytes()),
(MEMBER_LAYOUT, layout_json.as_bytes()),
(MEMBER_CHANGELOG, changelog_json.as_bytes()),
(MEMBER_PARSER_EQUIV, parser_equiv_json.as_bytes()),
]);
let lock = build_bundle_lock(
bundle_id,
lock_version,
lock_workbook_hash,
&ir_json,
&manifest_json,
&evidence_hash,
);
let lock_json = serde_json::to_string(&lock).unwrap();
let mut members = HashMap::new();
members.insert(MEMBER_IR.to_string(), ir_json.into_bytes());
members.insert(MEMBER_MANIFEST.to_string(), manifest_json.into_bytes());
members.insert(MEMBER_CELL_MAP.to_string(), cell_map_json.into_bytes());
members.insert(MEMBER_LAYOUT.to_string(), layout_json.into_bytes());
members.insert(MEMBER_CHANGELOG.to_string(), changelog_json.into_bytes());
members.insert(
MEMBER_PARSER_EQUIV.to_string(),
parser_equiv_json.into_bytes(),
);
members.insert(MEMBER_LOCK.to_string(), lock_json.into_bytes());
MapSource { members }
}
fn golden_with_versions(lock_version: &str, changelog_version: &str) -> MapSource {
let workbook_hash = sha256_hex(b"source-workbook-bytes");
golden_with(
lock_version,
changelog_version,
workbook_hash.clone(),
Some(&workbook_hash),
)
}
fn valid_golden() -> MapSource {
golden_with_versions("1.0.0", "1.0.0")
}
fn golden_with_absent_anchor_and_empty_lock_hash() -> MapSource {
golden_with("1.0.0", "1.0.0", String::new(), None)
}
#[test]
fn load_valid_golden_returns_populated_bundle() {
let source = valid_golden();
let bundle = load(&source).expect("valid golden loads");
assert_eq!(bundle.stamp.bundle_id, "tax-calc");
assert_eq!(bundle.stamp.version, "1.0.0");
let output_count: usize = bundle.cell_map.tools.iter().map(|t| t.outputs.len()).sum();
assert_eq!(output_count, 1);
assert_eq!(bundle.changelog.to_version, "1.0.0");
assert_eq!(bundle.manifest.workflow, "tax-calc");
}
#[test]
fn byte_flip_returns_integrity_mismatch() {
let mut source = valid_golden();
source.members.insert(
MEMBER_MANIFEST.to_string(),
br#"{"tampered":true}"#.to_vec(),
);
match load(&source) {
Err(BundleLoadError::IntegrityMismatch {
expected,
recomputed,
..
}) => {
assert_ne!(expected, recomputed, "diagnostic carries found-vs-expected");
},
other => panic!("expected IntegrityMismatch, got {other:?}"),
}
}
#[test]
fn version_desync_returns_stamp_mismatch() {
let source = golden_with_versions("1.0.0", "1.1.0");
match load(&source) {
Err(BundleLoadError::StampMismatch { field, .. }) => {
assert_eq!(field, "version");
},
other => panic!("expected StampMismatch on version, got {other:?}"),
}
}
#[test]
fn absent_layout_anchor_with_empty_lock_hash_fails_closed() {
let source = golden_with_absent_anchor_and_empty_lock_hash();
match load(&source) {
Err(BundleLoadError::StampMismatch {
field,
member_value,
..
}) => {
assert_eq!(field, "workbook_hash");
assert_eq!(
member_value, "<absent>",
"an absent anchor must be reported as <absent>, never defaulted to \"\""
);
},
other => panic!("expected StampMismatch <absent> on workbook_hash, got {other:?}"),
}
}
#[test]
fn malformed_member_returns_parse_not_panic() {
let mut source = valid_golden();
source
.members
.insert(MEMBER_LOCK.to_string(), b"{ not valid json".to_vec());
match load(&source) {
Err(BundleLoadError::Parse { what, .. }) => {
assert_eq!(what, MEMBER_LOCK);
},
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn unexpected_extra_member_fails_closed() {
let mut source = valid_golden();
source
.members
.insert("evidence/sneaky.json".to_string(), b"{}".to_vec());
match load(&source) {
Err(BundleLoadError::UnexpectedMember { member }) => {
assert_eq!(member, "evidence/sneaky.json");
},
other => panic!("expected UnexpectedMember, got {other:?}"),
}
}
#[test]
fn evidence_fold_members_const_is_sorted() {
assert!(
EVIDENCE_FOLD_MEMBERS.windows(2).all(|w| w[0] < w[1]),
"EVIDENCE_FOLD_MEMBERS must be declared in sorted relative-path order"
);
}
#[test]
fn recompute_evidence_hash_equals_lock_evidence_for_valid_golden() {
let source = valid_golden();
let lock: BundleLock =
parse_member(&source.read_artifact(MEMBER_LOCK).unwrap(), MEMBER_LOCK).unwrap();
let recomputed = recompute_evidence_hash(&source).unwrap();
assert_eq!(
recomputed, lock.artifacts.evidence,
"loader and generator must fold the identical evidence member set"
);
}
}