diff --git a/joulecontract/joulecontract-rs/crates/joulecontract-conformance/src/lib.rs b/joulecontract/joulecontract-rs/crates/joulecontract-conformance/src/lib.rs index 65643c875..8780003ac 100644 --- a/joulecontract/joulecontract-rs/crates/joulecontract-conformance/src/lib.rs +++ b/joulecontract/joulecontract-rs/crates/joulecontract-conformance/src/lib.rs @@ -416,3 +416,142 @@ mod vector_pack_tests { assert_eq!(statement.subject.len(), 2); } } + +#[cfg(test)] +mod negative_vector_replay { + use super::fixtures::appendix_a_sealed; + use joulecontract_receipt::{validate_sealed, Receipt, SealedContext}; + + /// Every published `negative-*` vector is replayed through the real + /// verifier and must reproduce its recorded verdict exactly. + /// + /// # What was missing + /// + /// The pack already had two tests: one checking every file's content + /// address against `MANIFEST.json`, and one verifying the positive DSSE + /// envelope. Neither *ran* a negative vector. They were published, + /// digest-pinned, and never executed — so a verifier that stopped demoting + /// would have kept every test green while the eleven vectors written to + /// catch exactly that sat on disk unread. + /// + /// # Why equality, not just "it demoted" + /// + /// These are degradation vectors, not accept/reject ones. Each records the + /// composed verdict a conformant verifier must produce: `metered_unattested` + /// demotes Energy to `ModelBased`, `generator_unattested` caps Governance at + /// `declared`, and so on. Asserting merely that *something* was demoted + /// would pass a verifier that demoted the wrong axis, or by the wrong + /// amount, or that refused the receipt outright — and refusing is not the + /// specified behaviour either. The whole content of these vectors is the + /// **exact** resulting claim, so the test compares all four recorded + /// fields: the demotion list, the maximum profile, the rendered §15 claim + /// string, and whether the claimed profile is permitted. + #[test] + fn every_negative_vector_reproduces_its_recorded_verdict() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/v1"); + // Assert, not skip — matching `vector_pack_tests`. An early `return` here + // would fire *before* the `replayed >= 11` guard below and let a missing + // pack pass vacuously, which is precisely the failure that guard exists to + // catch. The crate is not published, so it only runs in-repo, where the + // pack is always present. + assert!( + dir.exists(), + "conformance pack missing at {} — it is what this test replays", + dir.display() + ); + + // Rebuilt exactly as `gen_vectors` builds it: the negative cases mutate + // the receipt and share this one context. + let (contract, _r, bundle, log, interval) = appendix_a_sealed(); + let ctx = SealedContext { + bundle: &bundle, + key_log: &log, + interval: &interval, + transition_end_at: 150, + max_anchor_latency_secs: 3600, + open_breach_unamended: false, + }; + + let mut replayed = 0usize; + let mut entries: Vec<_> = std::fs::read_dir(&dir) + .expect("read conformance dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("negative-") && n.ends_with(".json")) + .collect(); + entries.sort(); + + for name in &entries { + let body = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let v: serde_json::Value = serde_json::from_slice(&body) + .unwrap_or_else(|e| panic!("{name}: parse: {e}")); + + // The context the vector was generated under must be the context we + // are replaying under, or the comparison is between two different + // questions. Checked rather than assumed. + let sc = &v["sealed_context"]; + assert_eq!(sc["transition_end_at"], 150, "{name}: context drifted"); + assert_eq!(sc["max_anchor_latency_secs"], 3600, "{name}: context drifted"); + assert_eq!(sc["open_breach_unamended"], false, "{name}: context drifted"); + + let receipt: Receipt = serde_json::from_value(v["receipt"].clone()) + .unwrap_or_else(|e| panic!("{name}: receipt: {e}")); + + // A negative vector is demoted, never malformed: the receipt must + // still validate structurally. A verifier that rejected it outright + // would be wrong in the other direction. + let verdict = validate_sealed(&contract, &receipt, &ctx) + .unwrap_or_else(|e| panic!("{name}: must demote, not refuse: {e}")); + + let expected = &v["expected_composed"]; + assert_eq!( + serde_json::to_value(&verdict.demotions).unwrap(), + expected["demotions"], + "{name}: demotion list" + ); + assert_eq!( + serde_json::to_value(&verdict.max_profile).unwrap(), + expected["max_profile"], + "{name}: maximum permitted profile" + ); + assert_eq!( + serde_json::Value::String(verdict.claim()), + expected["claim"], + "{name}: rendered claim" + ); + assert_eq!( + serde_json::Value::Bool(verdict.permits(&receipt.claimed_profile)), + expected["permits_claimed_profile"], + "{name}: whether the claimed profile is permitted" + ); + + // What makes a vector negative is that the verifier *flagged* + // something — not that it capped the profile. Two of the published + // eleven deliberately still permit the claimed profile: + // `recourse_undeclared` and `static_only_frame` record a + // **disclosure** demotion rather than a cap. `static_only` is + // prohibited only at JR-4 and these vectors claim JR-3, so the + // honest verdict is "permitted, and here is what you are trusting". + // + // So the invariant is the demotion list, not the permission bit. + // Asserting `!permits` here would have been wrong, and would have + // pressured those two vectors toward a stricter reading than the + // spec takes. + assert!( + !verdict.demotions.is_empty(), + "{name}: a negative vector that produces no demotion is not \ + testing anything" + ); + + replayed += 1; + } + + // Anti-vacuity. A wrong path, a renamed prefix or an empty glob would + // otherwise let this test pass having checked nothing — the same shape + // as the gap it was written to close. + assert!( + replayed >= 11, + "expected at least the 11 published negative vectors, replayed {replayed}" + ); + } +}