//! Audit log: cada acción mutadora del cerebro deja una entry inmutable //! con su predecesor encadenado por SHA256 (estilo Merkle). Verificable a //! posteriori sin confianza en quien escribe. //! //! Los entries viven en memoria. Para persistencia, `flush_to_cas()` los //! escribe al content-addressable store y devuelve el SHA del head, que //! puede guardarse en un archivo de "head pointer" (fuera de scope aquí). use crate::crystallize::Crystal; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use ulid::Ulid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditEntry { /// Sequence number monotónico desde el inicio del log. pub seq: u64, /// Wall-clock al insertar. pub timestamp_ms: u64, /// SHA256 del entry anterior. None para el primer entry. pub prev_sha: Option<[u8; 32]>, /// SHA256 de este entry (auto-calculado al construir). pub sha: [u8; 32], /// Acción registrada. pub action: AuditAction, } /// Sin `#[serde(tag)]`: bincode requiere external tagging (default serde /// para enums) para no usar `deserialize_any`. JSON sigue legible. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AuditAction { PromoteCrystal { rule_id: Ulid, crystal: Crystal }, RemoveRule { rule_id: Ulid }, LoadRulesFile { path: String, count: usize }, } pub struct AuditLog { entries: VecDeque, next_seq: u64, /// Cap del log en memoria. Entries más viejos se descartan tras flush. cap: usize, /// Total acumulado de entries flusheadas a CAS. flushed_count: u64, /// SHA del último entry persistido a CAS — el "head pointer" del log. last_flushed_sha: Option<[u8; 32]>, /// Path opcional donde escribir el head pointer tras cada flush. head_pointer_path: Option, } impl AuditLog { pub fn new() -> Self { Self::with_cap(512) } pub fn with_cap(cap: usize) -> Self { Self { entries: VecDeque::new(), next_seq: 0, cap, flushed_count: 0, last_flushed_sha: None, head_pointer_path: None, } } pub fn with_head_pointer(mut self, path: std::path::PathBuf) -> Self { self.head_pointer_path = Some(path); self } /// Apendea una acción. Calcula el SHA encadenado contra el último entry. pub fn append(&mut self, action: AuditAction) -> AuditEntry { let prev_sha = self.entries.back().map(|e| e.sha); let timestamp_ms = now_ms(); let seq = self.next_seq; self.next_seq += 1; // Pre-construct con sha en cero, luego calcular sha sobre el // serializado canónico, luego sobreescribir el campo. let mut entry = AuditEntry { seq, timestamp_ms, prev_sha, sha: [0u8; 32], action, }; entry.sha = compute_sha(&entry); if self.entries.len() >= self.cap { self.entries.pop_front(); } self.entries.push_back(entry.clone()); entry } pub fn recent(&self, limit: usize) -> impl Iterator { let n = if limit == 0 { self.entries.len() } else { limit.min(self.entries.len()) }; self.entries.iter().skip(self.entries.len() - n) } pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } pub fn head_sha(&self) -> Option<[u8; 32]> { self.entries.back().map(|e| e.sha) } /// Persiste el entry pasado al CAS y devuelve su SHA. Pensado para /// snapshots externos — el log en memoria sigue intacto. pub fn persist_to_cas(entry: &AuditEntry) -> anyhow::Result<[u8; 32]> { let bytes = serde_json::to_vec(entry)?; let sha = ente_cas::store(&bytes)?; Ok(sha) } /// Persiste TODOS los entries actuales al CAS y actualiza el head pointer. /// Idempotente: re-flushar dos veces da los mismos SHAs (CAS dedup). /// Devuelve cuántas entries se flushearon en esta pasada. /// /// Forma canónica: serializamos `entry` con `sha = [0; 32]` (formato /// pre-hash). El CAS computa sha256 sobre esos bytes y devuelve un SHA /// que por construcción coincide con `entry.sha` calculado al append. pub fn flush_to_cas(&mut self) -> anyhow::Result { let mut written = 0; let mut last_sha = self.last_flushed_sha; for entry in &self.entries { if entry.seq < self.flushed_count { continue; } let bytes = canonical_bytes(entry); let sha = ente_cas::store(&bytes)?; debug_assert_eq!(sha, entry.sha, "CAS sha != entry.sha — fórmula canónica rota"); last_sha = Some(sha); written += 1; } self.flushed_count += written as u64; self.last_flushed_sha = last_sha; // Persistir head pointer si está configurado. if let (Some(path), Some(sha)) = (&self.head_pointer_path, last_sha) { let pointer = AuditHeadPointer { last_seq: self.next_seq.saturating_sub(1), last_sha: sha, flushed_count: self.flushed_count, timestamp_ms: now_ms(), }; let json = serde_json::to_vec_pretty(&pointer)?; // Escritura atómica: tmp + rename let tmp = path.with_extension("tmp"); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } std::fs::write(&tmp, json)?; std::fs::rename(&tmp, path)?; } Ok(written) } pub fn flushed_count(&self) -> u64 { self.flushed_count } pub fn last_flushed_sha(&self) -> Option<[u8; 32]> { self.last_flushed_sha } } /// Pointer al head del audit log — escrito atómicamente en disco tras cada /// flush. Permite verificar la integridad del log sin escanearlo entero: /// el cliente lee el head, recupera el blob desde CAS, valida `prev_sha` /// recursivamente hasta el genesis. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditHeadPointer { pub last_seq: u64, pub last_sha: [u8; 32], pub flushed_count: u64, pub timestamp_ms: u64, } impl Default for AuditLog { fn default() -> Self { Self::new() } } fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } /// SHA256 sobre el entry en forma canónica (sha=[0;32]). Hash y CAS storage /// ven los mismos bytes, así que `ente_cas::store(canonical)` devuelve el /// mismo SHA que `compute_sha(entry)`. fn compute_sha(entry: &AuditEntry) -> [u8; 32] { let bytes = canonical_bytes(entry); ente_cas::sha256_of(&bytes) } /// Forma canónica: el entry serializado JSON con `sha = [0; 32]`. /// JSON sin pretty-print es determinístico para nuestros tipos. fn canonical_bytes(entry: &AuditEntry) -> Vec { let canonical = AuditEntry { sha: [0u8; 32], ..entry.clone() }; serde_json::to_vec(&canonical).unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn chain_links_consecutive_entries() { let mut log = AuditLog::new(); let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); assert!(e1.prev_sha.is_none()); assert_eq!(e2.prev_sha, Some(e1.sha)); assert_ne!(e1.sha, e2.sha); } #[test] fn seq_monotonic() { let mut log = AuditLog::new(); let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); assert_eq!(e2.seq, e1.seq + 1); } #[test] fn cap_evicts_oldest() { let mut log = AuditLog::with_cap(3); for _ in 0..5 { log.append(AuditAction::RemoveRule { rule_id: Ulid::new() }); } assert_eq!(log.len(), 3); // El primer seq superviviente debe ser 2. assert_eq!(log.recent(0).next().unwrap().seq, 2); } }