use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::error::Error;
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",
];
pub fn normalize_ts(value: &str) -> Result<String, Error> {
let s = value.trim();
let bytes = s.as_bytes();
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)?;
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()); }
frac = s[start..idx].to_string();
}
let off = &s[idx..];
let mut millis_str = frac;
millis_str.push_str("000");
let millis: i64 = millis_str[0..3].parse().map_err(|_| err())?;
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" => {}
_ => {
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")
}
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
}
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(),
}
}
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))
}
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())
}
pub fn payload_hash_hex(canonical: &[u8]) -> String {
let mut h = Sha256::new();
h.update(canonical);
hex::encode(h.finalize())
}