joulecontract: the published negative vectors were never actually run #94

Merged
dcharlot merged 2 commits from joulecontract/run-negative-vectors into main 2026-09-18 11:35:25 -04:00

View file

@ -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}"
);
}
}