use std::collections::BTreeMap;
use proto_blue_lex_cbor::encode;
use proto_blue_lex_data::LexValue;
use crate::error::{Error, Result};
pub const GENESIS_PREV_HASH: [u8; 32] = [0u8; 32];
pub struct AuditRowForHashing<'a> {
pub created_at: i64,
pub action: &'a str,
pub actor_did: &'a str,
pub target: Option<&'a str>,
pub target_cid: Option<&'a str>,
pub outcome: &'a str,
pub reason: Option<&'a str>,
}
pub fn compute_audit_row_hash(
prev_hash: &[u8; 32],
row: &AuditRowForHashing<'_>,
) -> Result<[u8; 32]> {
let canonical = encode(&audit_row_to_lex_value(row))?;
Ok(compute_chain_hash(prev_hash, &canonical))
}
pub(crate) fn compute_chain_hash(prev_hash: &[u8; 32], canonical: &[u8]) -> [u8; 32] {
let mut input = Vec::with_capacity(prev_hash.len() + canonical.len());
input.extend_from_slice(prev_hash);
input.extend_from_slice(canonical);
proto_blue_crypto::sha256(&input)
}
fn audit_row_to_lex_value(row: &AuditRowForHashing<'_>) -> LexValue {
let mut m = BTreeMap::new();
m.insert("created_at".to_string(), LexValue::Integer(row.created_at));
m.insert(
"action".to_string(),
LexValue::String(row.action.to_string()),
);
m.insert(
"actor_did".to_string(),
LexValue::String(row.actor_did.to_string()),
);
if let Some(target) = row.target {
m.insert("target".to_string(), LexValue::String(target.to_string()));
}
if let Some(target_cid) = row.target_cid {
m.insert(
"target_cid".to_string(),
LexValue::String(target_cid.to_string()),
);
}
m.insert(
"outcome".to_string(),
LexValue::String(row.outcome.to_string()),
);
if let Some(reason) = row.reason {
m.insert("reason".to_string(), LexValue::String(reason.to_string()));
}
LexValue::Map(m)
}
pub fn parse_stored_hash(bytes: &[u8]) -> Result<[u8; 32]> {
bytes.try_into().map_err(|_| {
Error::Signing(format!(
"stored audit row_hash has wrong length: {} bytes (expected 32)",
bytes.len()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_row<'a>() -> AuditRowForHashing<'a> {
AuditRowForHashing {
created_at: 1_776_902_400_000,
action: "label_applied",
actor_did: "did:plc:moderator0000000000000000",
target: Some("at://did:plc:target/col/r"),
target_cid: Some("bafytest"),
outcome: "success",
reason: Some(r#"{"val":"spam","neg":false,"moderator_reason":null}"#),
}
}
#[test]
fn deterministic_for_same_input() {
let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
let h2 = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
assert_eq!(h1, h2, "hash must be deterministic");
}
#[test]
fn genesis_sentinel_is_32_zero_bytes() {
assert_eq!(GENESIS_PREV_HASH, [0u8; 32]);
}
#[test]
fn different_prev_hash_produces_different_row_hash() {
let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
let h2 = compute_audit_row_hash(&[1u8; 32], &fixture_row()).unwrap();
assert_ne!(h1, h2, "prev_hash must affect row_hash");
}
#[test]
fn different_action_produces_different_row_hash() {
let mut row_a = fixture_row();
row_a.action = "label_applied";
let mut row_b = fixture_row();
row_b.action = "label_negated";
let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_a).unwrap();
let h2 = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_b).unwrap();
assert_ne!(h1, h2);
}
#[test]
fn absent_optional_differs_from_empty_string() {
let mut row_absent = fixture_row();
row_absent.target = None;
let mut row_empty = fixture_row();
row_empty.target = Some("");
let h_absent = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_absent).unwrap();
let h_empty = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_empty).unwrap();
assert_ne!(
h_absent, h_empty,
"absent target and empty target must hash distinctly"
);
}
#[test]
fn parse_stored_hash_round_trip() {
let computed = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
let parsed = parse_stored_hash(&computed).unwrap();
assert_eq!(parsed, computed);
}
#[test]
fn parse_stored_hash_rejects_wrong_length() {
let too_short = [0u8; 20];
assert!(parse_stored_hash(&too_short).is_err());
let too_long = [0u8; 64];
assert!(parse_stored_hash(&too_long).is_err());
}
#[test]
fn chain_link_locks_ordering() {
let row1 = AuditRowForHashing {
created_at: 1,
action: "label_applied",
actor_did: "did:plc:m1",
target: None,
target_cid: None,
outcome: "success",
reason: None,
};
let row2 = AuditRowForHashing {
created_at: 2,
action: "label_negated",
actor_did: "did:plc:m1",
target: None,
target_cid: None,
outcome: "success",
reason: None,
};
let row3 = AuditRowForHashing {
created_at: 3,
action: "report_resolved",
actor_did: "did:plc:m2",
target: None,
target_cid: None,
outcome: "success",
reason: None,
};
let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &row1).unwrap();
let h2 = compute_audit_row_hash(&h1, &row2).unwrap();
let h3 = compute_audit_row_hash(&h2, &row3).unwrap();
let h2_swapped = compute_audit_row_hash(&h1, &row3).unwrap();
let h3_swapped = compute_audit_row_hash(&h2_swapped, &row2).unwrap();
assert_ne!(h2, h2_swapped);
assert_ne!(h3, h3_swapped);
}
}