use std::fs;
use std::path::PathBuf;
use agent_scroll::{canonical, deserialize, seal_chain, serialize, verify, SignOpts};
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde_json::{json, Value};
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
}
fn gen_keypair() -> ([u8; 32], [u8; 32]) {
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
(sk.to_bytes(), pk)
}
#[test]
fn c1_byte_equality() {
let turns: Vec<Value> =
serde_json::from_slice(&fs::read(fixtures_dir().join("c1-turns.json")).unwrap()).unwrap();
let hex_map: serde_json::Map<String, Value> =
serde_json::from_slice(&fs::read(fixtures_dir().join("c1-hex.json")).unwrap()).unwrap();
assert!(
hex_map.len() >= 20,
"expected >=20 vectors, got {}",
hex_map.len()
);
for (k, t) in turns.iter().enumerate() {
let bytes = canonical(t).unwrap();
let actual = hex_lower(&bytes);
let expected = hex_map
.get(&k.to_string())
.expect("missing hex")
.as_str()
.unwrap();
assert_eq!(actual, expected, "C1: turn {k} mismatch");
}
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
#[test]
fn c2_mutation_detection() {
let (priv_key, pub_key) = gen_keypair();
let base = json!({
"version": "scroll/0.1",
"turn": 0,
"role": "user",
"model": { "vendor": "anthropic", "id": "claude-sonnet-4-5" },
"params": { "temperature": 0, "top_p": 1 },
"messages": [{ "role": "user", "content": "mutation test payload" }],
"timestamp_ns": 1700000000000000000u64,
});
let chain = seal_chain(
&[base],
Some(&SignOpts {
privkey: priv_key,
pubkey: pub_key,
}),
)
.unwrap();
let sealed = &chain[0];
let hash = sealed["hash"].as_str().unwrap();
let hash_hex = &hash[("sha256:".len())..];
let mut caught = 0;
for i in 0..hash_hex.len() {
let chars: Vec<char> = hash_hex.chars().collect();
let mut new_chars = chars.clone();
new_chars[i] = if chars[i] == '0' { 'f' } else { '0' };
let new_hash: String = new_chars.iter().collect();
let mut tampered = sealed.clone();
tampered["hash"] = json!(format!("sha256:{new_hash}"));
let r = verify(&[tampered], Some(&pub_key));
if !r.ok {
caught += 1;
}
}
assert_eq!(
caught,
hash_hex.len(),
"C2(A): caught {caught}/{}",
hash_hex.len()
);
let mut turn_only = sealed.as_object().unwrap().clone();
turn_only.remove("hash");
turn_only.remove("sig");
let body = canonical(&Value::Object(turn_only)).unwrap();
let limit = body.len().min(256);
let mut fails = 0;
for i in 0..limit {
let mut mutated = body.clone();
mutated[i] ^= 0x80;
let text = match std::str::from_utf8(&mutated) {
Ok(s) => s,
Err(_) => {
fails += 1;
continue;
}
};
let parsed: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => {
fails += 1;
continue;
}
};
if let Value::Object(mut obj) = parsed {
obj.insert("hash".into(), json!(hash));
if let Some(sig) = sealed.get("sig") {
obj.insert("sig".into(), sig.clone());
}
let chain = vec![Value::Object(obj)];
let r = verify(&chain, Some(&pub_key));
if !r.ok {
fails += 1;
}
} else {
fails += 1;
}
}
assert!(
fails >= (limit * 4 / 5),
"C2(B): only caught {fails}/{limit}"
);
}
#[test]
fn c3_roundtrip() {
let turns: Vec<Value> =
serde_json::from_slice(&fs::read(fixtures_dir().join("c1-turns.json")).unwrap()).unwrap();
for t in &turns {
let bytes = serialize(t).unwrap();
let recovered = deserialize(&bytes).unwrap();
assert_eq!(canonical(t).unwrap(), canonical(&recovered).unwrap());
}
let (priv_key, pub_key) = gen_keypair();
let unsigned = seal_chain(&turns[..5], None).unwrap();
for s in &unsigned {
let bytes = serialize(s).unwrap();
let recovered = deserialize(&bytes).unwrap();
assert_eq!(canonical(s).unwrap(), canonical(&recovered).unwrap());
}
let signed = seal_chain(
&turns[..5],
Some(&SignOpts {
privkey: priv_key,
pubkey: pub_key,
}),
)
.unwrap();
for s in &signed {
let bytes = serialize(s).unwrap();
let recovered = deserialize(&bytes).unwrap();
assert_eq!(canonical(s).unwrap(), canonical(&recovered).unwrap());
}
}
#[test]
fn c4_chain_tamper() {
let (priv_key, pub_key) = gen_keypair();
let mk = |i: usize, content: &str| {
json!({
"version": "scroll/0.1",
"turn": i,
"role": if i.is_multiple_of(2) { "user" } else { "assistant" },
"model": { "vendor": "anthropic", "id": "claude-sonnet-4-5" },
"params": { "temperature": 0, "top_p": 1 },
"messages": [{"role": if i.is_multiple_of(2) {"user"} else {"assistant"}, "content": content}],
"timestamp_ns": 1700000000000000000u64 + i as u64,
})
};
let raw: Vec<Value> = (0..5).map(|i| mk(i, &format!("msg{i}"))).collect();
let chain = seal_chain(
&raw,
Some(&SignOpts {
privkey: priv_key,
pubkey: pub_key,
}),
)
.unwrap();
assert_eq!(chain.len(), 5);
assert!(verify(&chain, Some(&pub_key)).ok);
let swapped = vec![
chain[0].clone(),
chain[2].clone(),
chain[1].clone(),
chain[3].clone(),
chain[4].clone(),
];
assert!(!verify(&swapped, Some(&pub_key)).ok);
let mut t3_mut = chain[3].clone();
t3_mut["prev_hash"] = json!(format!("sha256:{}", "a".repeat(64)));
let broken_prev = vec![
chain[0].clone(),
chain[1].clone(),
chain[2].clone(),
t3_mut,
chain[4].clone(),
];
assert!(!verify(&broken_prev, Some(&pub_key)).ok);
let mut t2_mut = chain[2].clone();
t2_mut["hash"] = json!(format!("sha256:{}", "b".repeat(64)));
let broken_hash = vec![
chain[0].clone(),
chain[1].clone(),
t2_mut,
chain[3].clone(),
chain[4].clone(),
];
assert!(!verify(&broken_hash, Some(&pub_key)).ok);
}