invoance 0.1.0

Official Rust SDK for the Invoance compliance API
Documentation
//! `invoance.audit/1` canonical serializer (client-side).
//!
//! Reproduces the server's frozen canonicalization so an audit event's
//! signature can be checked offline.
//!
//! Canonical bytes = build the signed object (signed fields present + non-null,
//! timestamps normalized, forced `schema_id`), strip null members recursively,
//! sort every object's keys alphabetically, emit compact UTF-8.

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

use crate::error::Error;

/// The frozen schema identifier for audit canonicalization.
pub const AUDIT_SCHEMA_ID: &str = "invoance.audit/1";

const SIGNED_FIELDS: [&str; 10] = [
    "org_id",
    "event_id",
    "seq",
    "ingested_at",
    "action",
    "occurred_at",
    "actor",
    "targets",
    "context",
    "metadata",
];

const REQUIRED_FIELDS: [&str; 8] = [
    "org_id",
    "event_id",
    "seq",
    "ingested_at",
    "action",
    "occurred_at",
    "actor",
    "targets",
];

/// Normalize an RFC3339 timestamp to the one canonical form: UTC, exactly 3
/// fractional digits (millis, **truncated** not rounded), trailing `Z`.
///
/// Emits `YYYY-MM-DDTHH:MM:SS.mmmZ`.
pub fn normalize_ts(value: &str) -> Result<String, Error> {
    let s = value.trim();
    let bytes = s.as_bytes();

    // Regex equivalent:
    // ^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|z|[+-]\d{2}:\d{2})$
    let err = || Error::validation(format!("invalid RFC3339 timestamp: {value}"));

    if bytes.len() < 20 {
        return Err(err());
    }
    let year: i64 = s.get(0..4).and_then(|x| x.parse().ok()).ok_or_else(err)?;
    if bytes.get(4) != Some(&b'-') {
        return Err(err());
    }
    let month: i64 = s.get(5..7).and_then(|x| x.parse().ok()).ok_or_else(err)?;
    if bytes.get(7) != Some(&b'-') {
        return Err(err());
    }
    let day: i64 = s.get(8..10).and_then(|x| x.parse().ok()).ok_or_else(err)?;
    match bytes.get(10) {
        Some(b'T') | Some(b't') => {}
        _ => return Err(err()),
    }
    let hh: i64 = s.get(11..13).and_then(|x| x.parse().ok()).ok_or_else(err)?;
    if bytes.get(13) != Some(&b':') {
        return Err(err());
    }
    let mi: i64 = s.get(14..16).and_then(|x| x.parse().ok()).ok_or_else(err)?;
    if bytes.get(16) != Some(&b':') {
        return Err(err());
    }
    let ss: i64 = s.get(17..19).and_then(|x| x.parse().ok()).ok_or_else(err)?;

    // Optional fractional seconds, then the mandatory offset.
    let mut idx = 19;
    let mut frac = String::new();
    if bytes.get(idx) == Some(&b'.') {
        idx += 1;
        let start = idx;
        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
            idx += 1;
        }
        if idx == start {
            return Err(err()); // "." with no digits
        }
        frac = s[start..idx].to_string();
    }

    let off = &s[idx..];
    // Truncate/pad the fractional part to exactly 3 digits (millis).
    let mut millis_str = frac;
    millis_str.push_str("000");
    let millis: i64 = millis_str[0..3].parse().map_err(|_| err())?;

    // Validate ranges loosely (chrono-free); days_from_civil tolerates them.
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
        return Err(err());
    }

    let mut epoch_ms =
        (days_from_civil(year, month, day) * 86_400 + hh * 3600 + mi * 60 + ss) * 1000 + millis;

    match off {
        "Z" | "z" => {}
        _ => {
            // [+-]HH:MM
            let ob = off.as_bytes();
            if ob.len() != 6 || ob[3] != b':' {
                return Err(err());
            }
            let sign = match ob[0] {
                b'+' => 1,
                b'-' => -1,
                _ => return Err(err()),
            };
            let oh: i64 = off.get(1..3).and_then(|x| x.parse().ok()).ok_or_else(err)?;
            let om: i64 = off.get(4..6).and_then(|x| x.parse().ok()).ok_or_else(err)?;
            epoch_ms -= sign * (oh * 3600 + om * 60) * 1000;
        }
    }

    Ok(format_epoch_ms_utc(epoch_ms))
}

fn format_epoch_ms_utc(epoch_ms: i64) -> String {
    let mut secs = epoch_ms.div_euclid(1000);
    let millis = epoch_ms.rem_euclid(1000);
    let days = secs.div_euclid(86_400);
    secs = secs.rem_euclid(86_400);
    let hh = secs / 3600;
    let mi = (secs % 3600) / 60;
    let ss = secs % 60;
    let (y, m, d) = civil_from_days(days);
    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mi:02}:{ss:02}.{millis:03}Z")
}

/// Days since the Unix epoch for a civil (proleptic Gregorian) date.
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146097 + doe - 719468
}

/// Inverse of [`days_from_civil`]: unix-epoch days → `(year, month, day)`.
fn civil_from_days(z: i64) -> (i64, i64, i64) {
    let z = z + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    (if m <= 2 { y + 1 } else { y }, m, d)
}

fn strip_nulls(v: &Value) -> Value {
    match v {
        Value::Array(a) => Value::Array(a.iter().map(strip_nulls).collect()),
        Value::Object(o) => {
            let mut out = Map::new();
            for (k, val) in o {
                if val.is_null() {
                    continue;
                }
                out.insert(k.clone(), strip_nulls(val));
            }
            Value::Object(out)
        }
        other => other.clone(),
    }
}

/// Recursively sort every object's keys alphabetically. We build the JSON
/// string ourselves so ordering is guaranteed regardless of `serde_json`'s
/// `preserve_order` feature state.
fn to_sorted_json(v: &Value, out: &mut String) {
    match v {
        Value::Object(o) => {
            let mut keys: Vec<&String> = o.keys().collect();
            keys.sort();
            out.push('{');
            for (i, k) in keys.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&serde_json::to_string(k).expect("string key serializes"));
                out.push(':');
                to_sorted_json(&o[*k], out);
            }
            out.push('}');
        }
        Value::Array(a) => {
            out.push('[');
            for (i, item) in a.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                to_sorted_json(item, out);
            }
            out.push(']');
        }
        scalar => {
            out.push_str(&serde_json::to_string(scalar).expect("scalar serializes"));
        }
    }
}

fn build_signed_object(event: &Value) -> Result<Value, Error> {
    let obj = event
        .as_object()
        .ok_or_else(|| Error::validation("event must be a JSON object"))?;

    for f in REQUIRED_FIELDS {
        match obj.get(f) {
            None | Some(Value::Null) => {
                return Err(Error::validation(format!("missing required field: {f}")))
            }
            _ => {}
        }
    }

    let mut out = Map::new();
    for f in SIGNED_FIELDS {
        match obj.get(f) {
            None | Some(Value::Null) => continue,
            Some(v) => {
                if f == "occurred_at" || f == "ingested_at" {
                    let s = v.as_str().ok_or_else(|| {
                        Error::validation(format!("{f} must be a string timestamp"))
                    })?;
                    out.insert(f.to_string(), Value::String(normalize_ts(s)?));
                } else {
                    out.insert(f.to_string(), v.clone());
                }
            }
        }
    }
    out.insert(
        "schema_id".to_string(),
        Value::String(AUDIT_SCHEMA_ID.to_string()),
    );
    Ok(Value::Object(out))
}

/// The canonical signed bytes for an audit event.
pub fn canonical_audit_bytes(event: &Value) -> Result<Vec<u8>, Error> {
    let signed = build_signed_object(event)?;
    let stripped = strip_nulls(&signed);
    let mut s = String::new();
    to_sorted_json(&stripped, &mut s);
    Ok(s.into_bytes())
}

/// `payload_hash = SHA-256(canonical bytes)`, lowercase hex.
pub fn payload_hash_hex(canonical: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(canonical);
    hex::encode(h.finalize())
}