use std::borrow::Cow;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::LibroError;
use crate::hasher::{ChainHasher, HASH_ALGORITHM};
pub const MAX_SOURCE_LEN: usize = 1024;
pub const MAX_ACTION_LEN: usize = 1024;
pub const MAX_DETAILS_SIZE: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EventSeverity {
Debug,
Info,
Warning,
Error,
Critical,
Security,
}
impl EventSeverity {
#[must_use]
pub fn at_or_above(self) -> &'static [EventSeverity] {
match self {
Self::Debug => &[
Self::Debug,
Self::Info,
Self::Warning,
Self::Error,
Self::Critical,
Self::Security,
],
Self::Info => &[
Self::Info,
Self::Warning,
Self::Error,
Self::Critical,
Self::Security,
],
Self::Warning => &[Self::Warning, Self::Error, Self::Critical, Self::Security],
Self::Error => &[Self::Error, Self::Critical, Self::Security],
Self::Critical => &[Self::Critical, Self::Security],
Self::Security => &[Self::Security],
}
}
#[inline]
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Debug => "Debug",
Self::Info => "Info",
Self::Warning => "Warning",
Self::Error => "Error",
Self::Critical => "Critical",
Self::Security => "Security",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditEntry {
id: Uuid,
timestamp: DateTime<Utc>,
severity: EventSeverity,
source: String,
action: String,
details: serde_json::Value,
agent_id: Option<String>,
prev_hash: String,
hash: String,
#[serde(default = "default_hash_algorithm")]
hash_algorithm: String,
}
fn default_hash_algorithm() -> String {
HASH_ALGORITHM.to_owned()
}
impl AuditEntry {
#[inline]
pub fn id(&self) -> Uuid {
self.id
}
#[inline]
pub fn timestamp(&self) -> DateTime<Utc> {
self.timestamp
}
#[inline]
pub fn severity(&self) -> EventSeverity {
self.severity
}
#[inline]
pub fn source(&self) -> &str {
&self.source
}
#[inline]
pub fn action(&self) -> &str {
&self.action
}
#[inline]
pub fn details(&self) -> &serde_json::Value {
&self.details
}
#[inline]
pub fn agent_id(&self) -> Option<&str> {
self.agent_id.as_deref()
}
#[inline]
pub fn prev_hash(&self) -> &str {
&self.prev_hash
}
#[inline]
pub fn hash(&self) -> &str {
&self.hash
}
#[inline]
pub fn hash_algorithm(&self) -> &str {
&self.hash_algorithm
}
}
impl AuditEntry {
pub fn new(
severity: EventSeverity,
source: impl Into<String>,
action: impl Into<String>,
details: serde_json::Value,
prev_hash: impl Into<String>,
) -> Self {
let mut entry = Self {
id: Uuid::new_v4(),
timestamp: Utc::now(),
severity,
source: source.into(),
action: action.into(),
details,
agent_id: None,
prev_hash: prev_hash.into(),
hash: String::new(),
hash_algorithm: HASH_ALGORITHM.to_owned(),
};
entry.hash = entry.compute_hash();
entry
}
pub fn new_validated(
severity: EventSeverity,
source: impl Into<String>,
action: impl Into<String>,
details: serde_json::Value,
prev_hash: impl Into<String>,
) -> crate::Result<Self> {
let source = source.into();
let action = action.into();
if source.len() > MAX_SOURCE_LEN {
return Err(LibroError::FieldTooLong {
field: "source",
len: source.len(),
max: MAX_SOURCE_LEN,
});
}
if action.len() > MAX_ACTION_LEN {
return Err(LibroError::FieldTooLong {
field: "action",
len: action.len(),
max: MAX_ACTION_LEN,
});
}
let details_size = serde_json::to_string(&details)
.map(|s| s.len())
.unwrap_or(0);
if details_size > MAX_DETAILS_SIZE {
return Err(LibroError::FieldTooLong {
field: "details",
len: details_size,
max: MAX_DETAILS_SIZE,
});
}
Ok(Self::new(severity, source, action, details, prev_hash))
}
#[allow(clippy::too_many_arguments, dead_code)]
pub(crate) fn from_raw(
id: Uuid,
timestamp: DateTime<Utc>,
severity: EventSeverity,
source: String,
action: String,
details: serde_json::Value,
agent_id: Option<String>,
prev_hash: String,
hash: String,
hash_algorithm: String,
) -> Self {
Self {
id,
timestamp,
severity,
source,
action,
details,
agent_id,
prev_hash,
hash,
hash_algorithm,
}
}
pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
self.agent_id = Some(agent_id.into());
self.hash = self.compute_hash();
self
}
#[must_use]
pub fn compute_hash(&self) -> String {
let mut hasher = ChainHasher::new();
hasher.update(self.id.as_bytes());
hash_field(&mut hasher, self.timestamp.to_rfc3339().as_bytes());
hash_field(&mut hasher, self.severity.as_str().as_bytes());
hash_field(&mut hasher, self.source.as_bytes());
hash_field(&mut hasher, self.action.as_bytes());
canonical_json_hash(&self.details, &mut hasher);
hash_field(
&mut hasher,
self.agent_id.as_deref().unwrap_or("").as_bytes(),
);
hash_field(&mut hasher, self.prev_hash.as_bytes());
hasher.finalize_hex()
}
#[must_use]
pub fn verify(&self) -> bool {
constant_time_eq(&self.hash, &self.compute_hash())
}
}
impl std::fmt::Display for EventSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::fmt::Display for AuditEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {} {}/{} hash={}",
self.timestamp.format("%Y-%m-%d %H:%M:%S"),
self.severity,
self.source,
self.action,
abbreviate_hash(&self.hash),
)?;
if let Some(ref agent) = self.agent_id {
write!(f, " agent={agent}")?;
}
Ok(())
}
}
pub(crate) fn abbreviate_hash(hash: &str) -> Cow<'_, str> {
if hash.len() > 12 {
Cow::Owned(format!("{}..{}", &hash[..8], &hash[hash.len() - 4..]))
} else {
Cow::Borrowed(hash)
}
}
#[inline]
pub(crate) fn constant_time_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub(crate) fn hash_field(hasher: &mut ChainHasher, data: &[u8]) {
hasher.update(&(data.len() as u64).to_le_bytes());
hasher.update(data);
}
fn canonical_json_hash(value: &serde_json::Value, hasher: &mut ChainHasher) {
match value {
serde_json::Value::Null => hasher.update(b"null"),
serde_json::Value::Bool(b) => {
hasher.update(if *b { "true" } else { "false" }.as_bytes());
}
serde_json::Value::Number(n) => hasher.update(n.to_string().as_bytes()),
serde_json::Value::String(s) => {
hasher.update(b"\"");
hasher.update(s.as_bytes());
hasher.update(b"\"");
}
serde_json::Value::Array(arr) => {
hasher.update(b"[");
for (i, v) in arr.iter().enumerate() {
if i > 0 {
hasher.update(b",");
}
canonical_json_hash(v, hasher);
}
hasher.update(b"]");
}
serde_json::Value::Object(map) => {
hasher.update(b"{");
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for (i, key) in keys.iter().enumerate() {
if i > 0 {
hasher.update(b",");
}
hasher.update(b"\"");
hasher.update(key.as_bytes());
hasher.update(b"\":");
canonical_json_hash(&map[*key], hasher);
}
hasher.update(b"}");
}
}
}
#[cfg(test)]
impl AuditEntry {
pub(crate) fn corrupt_action(&mut self, action: impl Into<String>) {
self.action = action.into();
}
pub(crate) fn corrupt_hash(&mut self, hash: impl Into<String>) {
self.hash = hash.into();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_creation() {
let entry = AuditEntry::new(
EventSeverity::Info,
"daimon",
"agent.registered",
serde_json::json!({"agent_id": "a1"}),
"",
);
assert!(!entry.hash().is_empty());
assert!(entry.verify());
}
#[test]
fn entry_tamper_detection() {
let mut entry = AuditEntry::new(
EventSeverity::Security,
"aegis",
"policy.violation",
serde_json::json!({}),
"",
);
let original_hash = entry.hash().to_owned();
entry.corrupt_action("tampered");
assert_ne!(entry.compute_hash(), original_hash);
assert!(!entry.verify());
}
#[test]
fn entry_chaining() {
let e1 = AuditEntry::new(EventSeverity::Info, "src", "act", serde_json::json!({}), "");
let e2 = AuditEntry::new(
EventSeverity::Info,
"src",
"act2",
serde_json::json!({}),
e1.hash(),
);
assert_eq!(e2.prev_hash(), e1.hash());
assert!(e2.verify());
}
#[test]
fn entry_with_agent() {
let entry = AuditEntry::new(EventSeverity::Info, "src", "act", serde_json::json!({}), "")
.with_agent("agent-123");
assert_eq!(entry.agent_id(), Some("agent-123"));
assert!(entry.verify());
}
#[test]
fn canonical_hash_covers_all_json_types() {
let details = serde_json::json!({
"z_last": null,
"a_first": true,
"numbers": [1, 2.5, -3],
"nested": {"b": "beta", "a": "alpha"},
"text": "hello"
});
let entry = AuditEntry::new(EventSeverity::Info, "src", "act", details, "");
assert!(entry.verify());
let details2 = serde_json::json!({
"text": "hello",
"nested": {"a": "alpha", "b": "beta"},
"numbers": [1, 2.5, -3],
"a_first": true,
"z_last": null
});
let entry2 = AuditEntry::new(EventSeverity::Info, "src", "act", details2, "");
assert!(entry2.verify());
}
#[test]
fn severity_as_str_all_variants() {
assert_eq!(EventSeverity::Debug.as_str(), "Debug");
assert_eq!(EventSeverity::Info.as_str(), "Info");
assert_eq!(EventSeverity::Warning.as_str(), "Warning");
assert_eq!(EventSeverity::Error.as_str(), "Error");
assert_eq!(EventSeverity::Critical.as_str(), "Critical");
assert_eq!(EventSeverity::Security.as_str(), "Security");
}
#[test]
fn accessors_return_correct_values() {
let entry = AuditEntry::new(
EventSeverity::Warning,
"aegis",
"scan.complete",
serde_json::json!({"count": 42}),
"prev123",
)
.with_agent("agent-x");
assert_eq!(entry.severity(), EventSeverity::Warning);
assert_eq!(entry.source(), "aegis");
assert_eq!(entry.action(), "scan.complete");
assert_eq!(entry.details(), &serde_json::json!({"count": 42}));
assert_eq!(entry.agent_id(), Some("agent-x"));
assert_eq!(entry.prev_hash(), "prev123");
assert!(!entry.hash().is_empty());
assert!(!entry.id().is_nil());
}
#[test]
fn severity_ordering() {
assert!(EventSeverity::Debug < EventSeverity::Info);
assert!(EventSeverity::Info < EventSeverity::Warning);
assert!(EventSeverity::Warning < EventSeverity::Error);
assert!(EventSeverity::Error < EventSeverity::Critical);
assert!(EventSeverity::Critical < EventSeverity::Security);
}
#[test]
fn severity_at_or_above() {
assert_eq!(EventSeverity::Warning.at_or_above().len(), 4);
assert_eq!(EventSeverity::Security.at_or_above().len(), 1);
assert_eq!(EventSeverity::Debug.at_or_above().len(), 6);
}
#[test]
fn field_boundary_not_ambiguous() {
let e1 = AuditEntry::new(EventSeverity::Info, "ab", "cd", serde_json::json!({}), "");
let e2 = AuditEntry::new(EventSeverity::Info, "abc", "d", serde_json::json!({}), "");
assert!(e1.verify());
assert!(e2.verify());
assert_ne!(e1.hash(), e2.hash());
}
#[test]
fn abbreviate_hash_long() {
let h = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
assert_eq!(super::abbreviate_hash(h), "a1b2c3d4..a1b2");
}
#[test]
fn abbreviate_hash_short() {
assert_eq!(super::abbreviate_hash("abc"), "abc");
assert_eq!(super::abbreviate_hash(""), "");
}
#[test]
fn display_entry_with_empty_hash() {
let entry = AuditEntry::from_raw(
uuid::Uuid::new_v4(),
chrono::Utc::now(),
EventSeverity::Info,
"src".into(),
"act".into(),
serde_json::json!({}),
None,
"".into(),
"".into(),
HASH_ALGORITHM.to_owned(),
);
let display = format!("{entry}");
assert!(display.contains("src/act"));
}
#[test]
fn canonical_json_key_order_determinism() {
let id = uuid::Uuid::new_v4();
let ts = chrono::Utc::now();
let details_a = serde_json::json!({"z": 1, "a": 2, "m": 3});
let details_b = serde_json::json!({"a": 2, "m": 3, "z": 1});
let entry_a = AuditEntry::from_raw(
id,
ts,
EventSeverity::Info,
"s".into(),
"a".into(),
details_a,
None,
"".into(),
String::new(),
HASH_ALGORITHM.to_owned(),
);
let entry_b = AuditEntry::from_raw(
id,
ts,
EventSeverity::Info,
"s".into(),
"a".into(),
details_b,
None,
"".into(),
String::new(),
HASH_ALGORITHM.to_owned(),
);
assert_eq!(entry_a.compute_hash(), entry_b.compute_hash());
}
#[test]
fn empty_source_and_action() {
let entry = AuditEntry::new(EventSeverity::Info, "", "", serde_json::json!(null), "");
assert!(entry.verify());
let display = format!("{entry}");
assert!(display.contains("/")); }
#[test]
fn serde_roundtrip() {
let entry = AuditEntry::new(
EventSeverity::Critical,
"phylax",
"threat.detected",
serde_json::json!({"file": "/tmp/bad"}),
"abc123",
);
let json = serde_json::to_string(&entry).unwrap();
let back: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.hash(), entry.hash());
assert!(back.verify());
}
#[test]
fn constant_time_eq_basic() {
assert!(super::constant_time_eq("abc", "abc"));
assert!(!super::constant_time_eq("abc", "abd"));
assert!(!super::constant_time_eq("abc", "ab"));
assert!(!super::constant_time_eq("", "a"));
assert!(super::constant_time_eq("", ""));
}
#[test]
fn new_validated_accepts_normal_input() {
let entry = AuditEntry::new_validated(
EventSeverity::Info,
"daimon",
"agent.start",
serde_json::json!({"ok": true}),
"",
)
.unwrap();
assert!(entry.verify());
}
#[test]
fn new_validated_rejects_oversized_source() {
let big = "x".repeat(super::MAX_SOURCE_LEN + 1);
let err =
AuditEntry::new_validated(EventSeverity::Info, big, "act", serde_json::json!({}), "")
.unwrap_err();
assert!(err.to_string().contains("source"));
}
#[test]
fn new_validated_rejects_oversized_action() {
let big = "x".repeat(super::MAX_ACTION_LEN + 1);
let err =
AuditEntry::new_validated(EventSeverity::Info, "src", big, serde_json::json!({}), "")
.unwrap_err();
assert!(err.to_string().contains("action"));
}
#[test]
fn new_validated_rejects_oversized_details() {
let big_str = "x".repeat(super::MAX_DETAILS_SIZE + 1);
let err = AuditEntry::new_validated(
EventSeverity::Info,
"src",
"act",
serde_json::json!({"data": big_str}),
"",
)
.unwrap_err();
assert!(err.to_string().contains("details"));
}
}