use std::{fs, path::PathBuf};
use pask_wire::{Payload, verify_chain};
use serde_json::{Value, json};
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/chains")
}
fn placeholder_digest(nibble: char) -> String {
format!("sha256:{}", nibble.to_string().repeat(64))
}
fn build_receipt(base: &Value, id: &str, seq: u64, prev_hash: Option<&str>) -> Value {
let mut receipt = base.clone();
receipt["id"] = json!(id);
receipt["chain"]["seq"] = json!(seq);
receipt["chain"]["prevHash"] = match prev_hash {
Some(hash) => json!(hash),
None => Value::Null,
};
receipt["chain"]["hash"] = json!(placeholder_digest('0'));
let bytes = serde_json::to_vec(&receipt).expect("receipt serializes");
let payload =
Payload::from_json_for_production(&bytes).expect("receipt is otherwise conforming");
let jcs = payload.to_jcs().expect("payload serializes to JCS");
serde_json::from_slice(&jcs).expect("JCS bytes are valid JSON")
}
fn chain_hash_of(receipt: &Value) -> String {
receipt["chain"]["hash"]
.as_str()
.expect("receipt carries a chain.hash string")
.to_owned()
}
fn wrap(description: &str, expect: &str, receipts: Vec<Value>) -> Value {
json!({
"description": description,
"expect": expect,
"receipts": receipts,
})
}
fn render(value: &Value) -> String {
format!(
"{}\n",
serde_json::to_string_pretty(value).expect("value serializes")
)
}
fn generate_expected() -> Vec<(&'static str, Value)> {
let example = pask_wire::canonical_example().expect("the canonical example emits");
let base: Value = serde_json::from_str(&example).expect("the canonical example is JSON");
let r0 = build_receipt(&base, "uuid:00000000-0000-4000-8000-0000000000a0", 0, None);
let r0_hash = chain_hash_of(&r0);
let r1 = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000a1",
1,
Some(&r0_hash),
);
let r1_hash = chain_hash_of(&r1);
let r2 = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000a2",
2,
Some(&r1_hash),
);
let valid_3 = wrap(
"Conforming three-receipt chain. Both Section 4.1 Chain-Verifier checks MUST pass.",
"verifies",
vec![r0, r1, r2],
);
let g0 = build_receipt(&base, "uuid:00000000-0000-4000-8000-0000000000b0", 0, None);
let g0_hash = chain_hash_of(&g0);
let g2 = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000b2",
2,
Some(&g0_hash),
);
let invalid_seq_gap = wrap(
"Two-receipt presentation whose second member is seq 2 rather than seq 1. \
The Chain-Verifier's seq-contiguity check MUST reject it.",
"rejected",
vec![g0, g2],
);
let b0 = build_receipt(&base, "uuid:00000000-0000-4000-8000-0000000000c0", 0, None);
let b0_hash = chain_hash_of(&b0);
let mut b1 = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000c1",
1,
Some(&b0_hash),
);
b1["chain"]["prevHash"] = json!(placeholder_digest('d'));
let b1_hash = chain_hash_of(&b1);
let b2 = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000c2",
2,
Some(&b1_hash),
);
let invalid_broken_link = wrap(
"Three-receipt presentation whose middle receipt's prevHash was altered after \
sealing, so it no longer matches the preceding receipt's chain.hash. Altering \
prevHash after chain.hash was computed also makes that receipt's chain.hash stale \
relative to its own content -- the realistic shape of in-band tampering -- so the \
per-receipt parser rejects it (chain.hash does not match payload) before the \
chain-level link check ever runs. Both checks catch it; this fixture exercises the \
per-receipt one, which fires first.",
"rejected",
vec![b0, b1, b2],
);
let head_prev = placeholder_digest('e');
let h0 = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000d0",
1,
Some(&head_prev),
);
let invalid_head_not_zero = wrap(
"Single-receipt presentation whose only member carries seq 1 instead of seq 0. \
The Chain-Verifier's head rule MUST reject it: the head of a presentation must \
be sequence zero.",
"rejected",
vec![h0],
);
vec![
("valid-3.json", valid_3),
("invalid-seq-gap.json", invalid_seq_gap),
("invalid-broken-link.json", invalid_broken_link),
("invalid-head-not-zero.json", invalid_head_not_zero),
]
}
#[test]
fn committed_fixtures_are_byte_identical_to_the_generator() {
let regenerate = std::env::var("PASK_REGEN_FIXTURES").is_ok_and(|value| value == "1");
let dir = fixtures_dir();
for (name, expected) in generate_expected() {
let expected_text = render(&expected);
let path = dir.join(name);
if regenerate {
fs::write(&path, &expected_text)
.unwrap_or_else(|error| panic!("{} is writable: {error}", path.display()));
continue;
}
let committed = fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("{} is readable: {error}", path.display()));
assert_eq!(
committed,
expected_text,
"{} has drifted from the generator in this test. Do not hand-edit the \
fixture -- regenerate it from this test's expected content.",
path.display()
);
}
}
fn load_receipts(name: &str) -> Vec<Payload> {
let path = fixtures_dir().join(name);
let text = fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("{} is readable: {error}", path.display()));
let value: Value = serde_json::from_str(&text).expect("fixture is valid JSON");
value["receipts"]
.as_array()
.expect("fixture carries a receipts array")
.iter()
.map(|receipt| {
let bytes = serde_json::to_vec(receipt).expect("receipt serializes");
Payload::from_json(&bytes).expect("fixture receipt parses and validates on its own")
})
.collect()
}
fn try_load_receipts(name: &str) -> Result<Vec<Payload>, pask_wire::Error> {
let path = fixtures_dir().join(name);
let text = fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("{} is readable: {error}", path.display()));
let value: Value = serde_json::from_str(&text).expect("fixture is valid JSON");
value["receipts"]
.as_array()
.expect("fixture carries a receipts array")
.iter()
.map(|receipt| {
let bytes = serde_json::to_vec(receipt).expect("receipt serializes");
Payload::from_json(&bytes)
})
.collect()
}
#[test]
fn valid_three_receipt_chain_verifies() {
let receipts = load_receipts("valid-3.json");
let report = verify_chain(&receipts).expect("the conforming chain verifies");
assert!(
report.affiliation_is_uniform(),
"this chain does not change issuerAffiliation, so the report must be empty"
);
}
#[test]
fn seq_gap_is_rejected_with_the_specific_error() {
let receipts = load_receipts("invalid-seq-gap.json");
assert_eq!(
verify_chain(&receipts),
Err(pask_wire::Error::Validation("chain.seq is not contiguous"))
);
}
#[test]
fn broken_link_fixture_is_rejected_by_per_receipt_validation_before_verify_chain_runs() {
let result = try_load_receipts("invalid-broken-link.json");
assert_eq!(
result,
Err(pask_wire::Error::Validation(
"chain.hash does not match payload"
))
);
}
#[test]
fn chain_level_link_check_rejects_a_mismatched_prev_hash_that_still_parses() {
let receipts = load_receipts("valid-3.json");
let example = pask_wire::canonical_example().expect("the canonical example emits");
let base: Value = serde_json::from_str(&example).expect("the canonical example is JSON");
let wrong_prev = placeholder_digest('f');
let tampered_second = build_receipt(
&base,
"uuid:00000000-0000-4000-8000-0000000000a9",
1,
Some(&wrong_prev),
);
let bytes = serde_json::to_vec(&tampered_second).expect("receipt serializes");
let tampered_second =
Payload::from_json(&bytes).expect("a receipt with a self-consistent chain.hash parses");
let presentation = [receipts[0].clone(), tampered_second];
assert_eq!(
verify_chain(&presentation),
Err(pask_wire::Error::Validation(
"chain.prevHash does not match the preceding receipt"
))
);
}
#[test]
fn head_not_zero_is_rejected_with_the_specific_error() {
let receipts = load_receipts("invalid-head-not-zero.json");
assert_eq!(
verify_chain(&receipts),
Err(pask_wire::Error::Validation(
"chain head must have seq 0 and a null prevHash"
))
);
}
#[test]
fn single_receipt_chain_at_seq_zero_verifies() {
let receipts = load_receipts("valid-3.json");
let report = verify_chain(&receipts[..1]).expect("a single receipt at seq 0 verifies");
assert!(report.affiliation_is_uniform());
}
#[test]
fn empty_slice_is_rejected() {
let receipts: Vec<Payload> = Vec::new();
assert_eq!(
verify_chain(&receipts),
Err(pask_wire::Error::Validation(
"chain must carry at least one receipt"
))
);
}