agent-toolprint 0.1.0

Double-signed receipts for AI-agent tool invocations — DSSE + JCS + Ed25519, verifiable offline (Rust port of @p-vbordei/agent-toolprint)
Documentation
//! RFC 8785 JSON Canonicalization Scheme + SHA-256 helpers.

use serde_json::{Number, Value};
use sha2::{Digest, Sha256};

use crate::error::Error;

/// RFC 8785 requires all JSON numbers be formatted via ECMA-262 ToString of f64.
/// `serde_jcs` preserves u64/i64 above 2^53, so we coerce those to f64 first to
/// match the precision-loss behaviour of the TypeScript reference. Receipts in
/// v0.1 do not currently carry large integers, but applying this defensively
/// keeps byte-equality stable if a future schema adds a `timestamp_ns`, nonce,
/// or counter field.
fn normalize_numbers(value: Value) -> Value {
    const SAFE: u64 = 1u64 << 53;
    match value {
        Value::Object(map) => Value::Object(
            map.into_iter()
                .map(|(k, v)| (k, normalize_numbers(v)))
                .collect(),
        ),
        Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_numbers).collect()),
        Value::Number(n) => {
            if let Some(u) = n.as_u64() {
                if u > SAFE {
                    return Number::from_f64(u as f64)
                        .map(Value::Number)
                        .unwrap_or(Value::Number(n));
                }
            } else if let Some(i) = n.as_i64() {
                if i.unsigned_abs() > SAFE {
                    return Number::from_f64(i as f64)
                        .map(Value::Number)
                        .unwrap_or(Value::Number(n));
                }
            }
            Value::Number(n)
        }
        other => other,
    }
}

pub fn canonical(value: &Value) -> Result<Vec<u8>, Error> {
    let normalized = normalize_numbers(value.clone());
    serde_jcs::to_string(&normalized)
        .map(|s| s.into_bytes())
        .map_err(|e| Error::Invalid(format!("jcs: {e}")))
}

pub fn sha256_hash(value: &Value) -> Result<String, Error> {
    let bytes = canonical(value)?;
    let digest = Sha256::digest(&bytes);
    Ok(format!("sha256:{}", hex_lower(&digest)))
}

pub 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
}