Skip to main content

pmcp_workbook_runtime/
bundle_loader.rs

1//! The single shared, fail-closed [`load`] bundle verifier (Phase 92, Plan 01 —
2//! WBSV-08, threats T-92-01/02/04/22).
3//!
4//! Every [`crate::bundle_source::BundleSource`] (local-dir or embedded) is parsed
5//! and integrity-checked HERE and ONLY here, so no source impl can skip the gate
6//! (the trait returns raw bytes only — threat T-92-03). [`load`]:
7//!
8//! 1. enforces a FAIL-CLOSED membership allow-set — any unexpected/extra member
9//!    is rejected with [`BundleLoadError::UnexpectedMember`] BEFORE parsing
10//!    (frozen-bundle contract, threat T-92-22);
11//! 2. recomputes the evidence-dir hash (path+length-prefixed, SORTED) via the
12//!    runtime's own shared [`crate::artifact_model::fold_evidence_hash`];
13//! 3. recomputes the per-artifact + combined `BUNDLE.lock` hashes via the
14//!    runtime's own [`crate::artifact_model::build_bundle_lock`] (it does NOT
15//!    re-implement hashing), and fails closed on any mismatch
16//!    ([`BundleLoadError::IntegrityMismatch`], threat T-92-01);
17//! 4. cross-checks the lock's identity/provenance triple against
18//!    independently-hash-covered members ([`BundleLoadError::StampMismatch`],
19//!    threat T-92-02);
20//! 5. parses every member total + panic-free ([`BundleLoadError::Parse`],
21//!    threat T-92-04) and builds the per-cell DAG ONCE.
22//!
23//! It returns a fully-verified [`WorkbookBundle`].
24
25use 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
37/// The bundle member holding the executable IR (a `HashMap<String, Cell>`).
38pub const MEMBER_IR: &str = "executable.ir.json";
39/// The bundle member holding the logical manifest.
40pub const MEMBER_MANIFEST: &str = "manifest.json";
41/// The bundle member holding the I/O cell map.
42pub const MEMBER_CELL_MAP: &str = "cell_map.json";
43/// The bundle member holding the captured layout descriptor.
44pub const MEMBER_LAYOUT: &str = "layout.json";
45/// The bundle member holding the integrity lock.
46pub const MEMBER_LOCK: &str = "BUNDLE.lock";
47/// The bundle member holding the recorded version changelog.
48pub const MEMBER_CHANGELOG: &str = "evidence/changelog.json";
49/// The bundle member holding the parser-equivalence evidence record.
50pub const MEMBER_PARSER_EQUIV: &str = "evidence/parser_equivalence.json";
51
52/// The FROZEN member allow-set (threat T-92-22): the bundle MUST contain exactly
53/// these members — any member outside this set fails closed BEFORE parsing.
54///
55/// Exported so the fixture generator and future emitters share the loader's
56/// canonical member-name table instead of re-declaring it.
57pub 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
67/// The members folded into the evidence-dir hash — the evidence members PLUS
68/// `cell_map.json` + `layout.json`, matching the emitter's fold (Pitfall 2: the
69/// generator and loader MUST fold the identical set). Declared in SORTED
70/// relative-path order (asserted by test) so the fold iterates it directly.
71pub const EVIDENCE_FOLD_MEMBERS: &[&str] = &[
72    MEMBER_CELL_MAP,
73    MEMBER_CHANGELOG,
74    MEMBER_PARSER_EQUIV,
75    MEMBER_LAYOUT,
76];
77
78/// The fully-parsed, integrity-verified bundle the served tools operate on.
79///
80/// Returned by [`load`] ONLY after every fail-closed gate passes, so a
81/// `WorkbookBundle` value is proof the bundle was untampered at load.
82#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct WorkbookBundle {
85    /// The executable IR (cell key → [`Cell`]).
86    pub ir: HashMap<String, Cell>,
87    /// The per-cell dependency DAG, built ONCE at load.
88    pub dag: Dag,
89    /// The logical manifest projection.
90    pub manifest: Manifest,
91    /// The I/O cell map (inputs/outputs).
92    pub cell_map: CellMap,
93    /// The captured layout descriptor.
94    pub layout: LayoutDescriptor,
95    /// The recorded version changelog.
96    pub changelog: VersionChangelog,
97    /// The verified integrity lock (the served provenance stamp source).
98    pub stamp: BundleLock,
99}
100
101/// Errors [`load`] surfaces — every one is fail-closed (the bundle is rejected,
102/// the server never boots on a tampered/malformed bundle).
103///
104/// `#[non_exhaustive]` so future verification gates add variants additively.
105#[derive(Debug, Error)]
106#[non_exhaustive]
107pub enum BundleLoadError {
108    /// A member's bytes could not be read from the source.
109    #[error("bundle source error reading {member}: {detail}")]
110    Source {
111        /// The member that failed to read.
112        member: String,
113        /// The underlying source error detail.
114        detail: String,
115    },
116
117    /// A member's JSON could not be parsed (malformed / truncated — T-92-04).
118    #[error("failed to parse bundle member {what}: {detail}")]
119    Parse {
120        /// The member that failed to parse.
121        what: String,
122        /// The serde parse-error detail.
123        detail: String,
124    },
125
126    /// The recomputed integrity hashes do not match the on-disk lock (a tampered
127    /// or swapped artifact — threat T-92-01). Carries a FOUND-vs-EXPECTED
128    /// diagnostic.
129    #[error(
130        "bundle integrity mismatch: expected combined {expected}, recomputed {recomputed} \
131         (expected evidence {expected_evidence}, recomputed {recomputed_evidence})"
132    )]
133    IntegrityMismatch {
134        /// The combined hash recorded in the on-disk lock.
135        expected: String,
136        /// The combined hash recomputed from the member bytes.
137        recomputed: String,
138        /// The evidence hash recorded in the on-disk lock.
139        expected_evidence: String,
140        /// The evidence hash recomputed from the member bytes.
141        recomputed_evidence: String,
142    },
143
144    /// The lock's identity/provenance triple does not bind to an independently
145    /// hash-covered member (a tampered lock — threat T-92-02).
146    #[error(
147        "bundle stamp mismatch on {field}: lock has {lock_value:?} but {member} has {member_value:?}"
148    )]
149    StampMismatch {
150        /// The lock field that failed to bind (`workbook_hash`/`bundle_id`/`version`).
151        field: &'static str,
152        /// The value recorded in the lock.
153        lock_value: String,
154        /// The value found in the cross-checked member.
155        member_value: String,
156        /// The member the field was cross-checked against.
157        member: &'static str,
158    },
159
160    /// The bundle contains a member outside the frozen allow-set (threat T-92-22).
161    #[error("unexpected bundle member (not in the frozen allow-set): {member}")]
162    UnexpectedMember {
163        /// The unexpected member's bundle-relative path.
164        member: String,
165    },
166}
167
168/// Read one member's bytes, mapping a source failure to a tagged [`BundleLoadError::Source`].
169fn 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
182/// Parse one member's JSON bytes, mapping any failure to [`BundleLoadError::Parse`].
183fn 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
193/// Recompute the evidence-dir hash the way the emitter folded it: read the
194/// [`EVIDENCE_FOLD_MEMBERS`] bytes and feed them to the runtime's own shared
195/// [`fold_evidence_hash`] (so it byte-reproduces the emitter by construction).
196fn 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
205/// Enforce the FAIL-CLOSED membership allow-set (threat T-92-22): reject ANY
206/// member outside the frozen [`ALLOWED_MEMBERS`] set BEFORE parsing.
207fn 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
227/// Decode one member's bytes as UTF-8, mapping a non-UTF-8 body to
228/// [`BundleLoadError::Parse`] (the integrity recompute hashes the JSON text).
229fn 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
236/// Parse the lock then recompute integrity via the runtime's OWN hasher
237/// (threat T-92-01), failing closed on any artifact/combined-hash mismatch.
238///
239/// Returns the verified lock plus the raw IR/manifest bytes (already read here)
240/// so the caller parses them ONCE without re-reading the source.
241fn 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
273/// The total + panic-free parse of every member needed to assemble the bundle
274/// (threat T-92-04). The IR/manifest bytes were already read by the integrity
275/// step, so they are parsed from the passed-in bytes rather than re-read.
276struct ParsedMembers {
277    ir: HashMap<String, Cell>,
278    manifest: Manifest,
279    cell_map: CellMap,
280    layout: LayoutDescriptor,
281    changelog: VersionChangelog,
282}
283
284/// Parse every bundle member into its typed value (threat T-92-04). `ir_bytes`
285/// and `manifest_bytes` come from [`verify_integrity`]; the remaining members
286/// are read here.
287fn 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
308/// Cross-check the lock's identity/provenance triple against independently
309/// hash-covered members (threat T-92-02). The recompute necessarily feeds the
310/// lock's own `bundle_id`/`version`/`workbook_hash` from the lock itself, so a
311/// tampered lock that rewrites the triple would pass the integrity recompute —
312/// this binding is what catches it.
313fn verify_stamp_binding(
314    lock: &BundleLock,
315    manifest: &Manifest,
316    layout: &LayoutDescriptor,
317    changelog: &VersionChangelog,
318) -> Result<(), BundleLoadError> {
319    // workbook_hash ↔ layout.source_workbook_hash. An ABSENT anchor makes the
320    // binding impossible — reject it explicitly (WR-07). Defaulting to "" would let
321    // an absent anchor + empty lock.workbook_hash pass vacuously ("" == ""); the
322    // emitter always stamps the anchor, so an absent one is a tampered/partial bundle.
323    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
358/// Load + fail-closed integrity-verify a bundle from any [`BundleSource`].
359///
360/// This is the SINGLE shared verifier (WBSV-08): a local-dir bundle and an
361/// embedded bundle are checked identically. Returns a fully-verified
362/// [`WorkbookBundle`] or a fail-closed [`BundleLoadError`].
363///
364/// # Errors
365///
366/// Returns [`BundleLoadError::UnexpectedMember`] for an extra member,
367/// [`BundleLoadError::IntegrityMismatch`] for a byte-flip/swap,
368/// [`BundleLoadError::StampMismatch`] for a provenance desync,
369/// [`BundleLoadError::Parse`] for malformed JSON, or
370/// [`BundleLoadError::Source`] for a read failure.
371pub fn load(source: &dyn BundleSource) -> Result<WorkbookBundle, BundleLoadError> {
372    // 1. Fail-closed membership policy (threat T-92-22).
373    enforce_member_allow_set(source)?;
374
375    // 2. Parse the lock + recompute integrity via the runtime's OWN hasher
376    //    (threat T-92-01), reusing the IR/manifest bytes it already read.
377    let (lock, ir_bytes, manifest_bytes) = verify_integrity(source)?;
378
379    // 3. Parse every member (total + panic-free; threat T-92-04).
380    let members = parse_members(source, &ir_bytes, &manifest_bytes)?;
381
382    // 4. Cross-check the provenance triple (threat T-92-02).
383    verify_stamp_binding(
384        &lock,
385        &members.manifest,
386        &members.layout,
387        &members.changelog,
388    )?;
389
390    // 5. Build the per-cell DAG ONCE at load.
391    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    /// An in-memory [`BundleSource`] backed by a member map — the loader tests
412    /// build a valid golden, then tamper one member to prove fail-closed.
413    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    /// Build a golden bundle: serialize every member, fold the evidence hash via
489    /// the shared [`fold_evidence_hash`], build the lock over the member bytes,
490    /// then assemble the source map. `lock_workbook_hash` and `layout_anchor`
491    /// diverge from each other only in the stamp-binding tests.
492    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    /// A golden with a consistent workbook-hash stamp; `lock_version` and
541    /// `changelog_version` diverge only in the stamp-desync test.
542    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    /// A fully self-consistent golden (every gate passes).
553    fn valid_golden() -> MapSource {
554        golden_with_versions("1.0.0", "1.0.0")
555    }
556
557    /// WR-07 fixture: a golden whose `layout.source_workbook_hash` is ABSENT
558    /// (`None`) and whose `lock.workbook_hash` is the empty string. Every integrity
559    /// hash is recomputed over these exact bytes so the integrity gate passes — the
560    /// stamp gate (absent-anchor rejection) is what must fire, NOT a vacuous
561    /// `"" == ""` pass.
562    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        // Flip one byte of the manifest member (recomputed hash diverges).
582        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        // A golden whose lock says 1.0.0 but changelog.to_version=1.1.0, with
601        // integrity hashes self-consistent so the stamp gate (not the integrity
602        // gate) is what fires.
603        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        // WR-07: an absent layout.source_workbook_hash MUST be rejected even when
616        // lock.workbook_hash is empty — the old empty-default made this pass vacuously
617        // (empty == empty). The stamp gate must fire with member_value "<absent>".
618        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        // Corrupt the lock JSON so the FIRST parse (the lock) fails closed.
639        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        // EVIDENCE_FOLD_MEMBERS is declared pre-sorted so the fold iterates it
667        // directly; this guard keeps the declaration honest.
668        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        // Pitfall 2 guard: the loader's evidence fold byte-reproduces the
677        // generator's, so the recomputed evidence hash equals lock.artifacts.evidence.
678        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}