use serde_json::Value;
use crate::provenance::sha256_hex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CanonicalError {
FloatNotPermitted { path: String },
NullNotPermitted { path: String },
ProvenanceMissing,
}
impl std::fmt::Display for CanonicalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CanonicalError::FloatNotPermitted { path } => write!(
f,
"CP/1 canonical form permits integers only; found a floating-point number at {path}"
),
CanonicalError::NullNotPermitted { path } => write!(
f,
"CP/1 canonical form omits absent values rather than writing null; found null at {path}"
),
CanonicalError::ProvenanceMissing => f.write_str(
"a CP/1 document must carry a `provenance` object before it can be sealed",
),
}
}
}
impl std::error::Error for CanonicalError {}
pub fn to_canonical(value: &Value) -> Result<String, CanonicalError> {
let mut out = String::new();
write_value(value, "$", &mut out)?;
Ok(out)
}
fn write_value(value: &Value, path: &str, out: &mut String) -> Result<(), CanonicalError> {
match value {
Value::Null => Err(CanonicalError::NullNotPermitted {
path: path.to_string(),
}),
Value::Bool(b) => {
out.push_str(if *b { "true" } else { "false" });
Ok(())
}
Value::Number(n) => {
if n.is_f64() {
return Err(CanonicalError::FloatNotPermitted {
path: path.to_string(),
});
}
out.push_str(&n.to_string());
Ok(())
}
Value::String(s) => {
out.push_str(&serde_json::to_string(s).expect("a string always serializes"));
Ok(())
}
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_value(item, &format!("{path}[{i}]"), out)?;
}
out.push(']');
Ok(())
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
out.push('{');
for (i, key) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(key).expect("a string always serializes"));
out.push(':');
write_value(&map[key], &format!("{path}.{key}"), out)?;
}
out.push('}');
Ok(())
}
}
}
pub fn content_hash(document: &Value) -> Result<String, CanonicalError> {
let mut unsealed = document.clone();
if let Some(provenance) = unsealed
.get_mut("provenance")
.and_then(Value::as_object_mut)
{
provenance.remove("content_hash");
}
Ok(sha256_hex(to_canonical(&unsealed)?.as_bytes()))
}
pub fn seal(document: &mut Value) -> Result<String, CanonicalError> {
let hash = content_hash(document)?;
let provenance = document
.get_mut("provenance")
.and_then(Value::as_object_mut)
.ok_or(CanonicalError::ProvenanceMissing)?;
provenance.insert("content_hash".to_string(), Value::String(hash.clone()));
Ok(hash)
}
pub fn verify_seal(document: &Value) -> Result<bool, CanonicalError> {
let recorded = document
.get("provenance")
.and_then(|p| p.get("content_hash"))
.and_then(Value::as_str);
match recorded {
Some(recorded) => Ok(recorded == content_hash(document)?),
None => Ok(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn object_keys_are_sorted_at_every_depth() {
let value = json!({ "z": 1, "a": { "y": 2, "b": 3 } });
assert_eq!(to_canonical(&value).unwrap(), r#"{"a":{"b":3,"y":2},"z":1}"#);
}
#[test]
fn array_order_is_preserved() {
let value = json!({ "xs": [3, 1, 2] });
assert_eq!(to_canonical(&value).unwrap(), r#"{"xs":[3,1,2]}"#);
}
#[test]
fn strings_use_short_escapes_and_keep_non_ascii_literal() {
let value = json!({ "s": "a\"b\\c\nd\te—f" });
assert_eq!(
to_canonical(&value).unwrap(),
"{\"s\":\"a\\\"b\\\\c\\nd\\te—f\"}"
);
}
#[test]
fn floats_are_rejected_with_their_location() {
let value = json!({ "outer": { "fitness": 0.5 } });
assert_eq!(
to_canonical(&value),
Err(CanonicalError::FloatNotPermitted {
path: "$.outer.fitness".to_string()
})
);
}
#[test]
fn nulls_are_rejected_with_their_location() {
let value = json!({ "xs": [1, null] });
assert_eq!(
to_canonical(&value),
Err(CanonicalError::NullNotPermitted {
path: "$.xs[1]".to_string()
})
);
}
#[test]
fn integers_render_without_fraction_or_exponent() {
let value = json!({ "big": 4294967295u32, "neg": -10000, "zero": 0 });
assert_eq!(
to_canonical(&value).unwrap(),
r#"{"big":4294967295,"neg":-10000,"zero":0}"#
);
}
#[test]
fn seal_then_verify_round_trips_and_detects_tampering() {
let mut document = json!({
"cp": "cp1",
"type": "Belief",
"statement": "tests catch regressions",
"provenance": { "authored_by": "adam", "evidence": [] }
});
let hash = seal(&mut document).unwrap();
assert_eq!(hash.len(), 64);
assert!(verify_seal(&document).unwrap());
document["statement"] = json!("tests do not catch regressions");
assert!(
!verify_seal(&document).unwrap(),
"changing the payload must invalidate the seal"
);
}
#[test]
fn seal_ignores_a_previously_recorded_hash() {
let mut document = json!({
"statement": "x",
"provenance": { "content_hash": "0".repeat(64) }
});
let first = seal(&mut document).unwrap();
let second = seal(&mut document).unwrap();
assert_eq!(first, second);
}
#[test]
fn a_document_without_provenance_never_verifies() {
let document = json!({ "statement": "x" });
assert!(!verify_seal(&document).unwrap());
}
#[test]
fn sealing_a_document_without_provenance_is_refused_at_the_sender() {
let mut document = json!({ "statement": "x" });
assert_eq!(seal(&mut document), Err(CanonicalError::ProvenanceMissing));
}
#[test]
fn keys_are_ordered_by_utf8_bytes_including_beyond_the_bmp() {
let value = json!({ "\u{1D11E}": 1, "\u{FFFD}": 2 });
assert_eq!(
to_canonical(&value).unwrap(),
"{\"\u{FFFD}\":2,\"\u{1D11E}\":1}"
);
}
}