tawasuyu
⎇ a4fa42c781d76852355931d82a28e80230855af8 crates/ente-brain/src/audit.rs Rust · 228 lines · 8.0 KB History Who touched what raw

Audit→CAS, reload rules, time-decay y forma canónica del hash chain a4fa42c78 · Sergio ·

1//! Audit log: cada acción mutadora del cerebro deja una entry inmutable
2//! con su predecesor encadenado por SHA256 (estilo Merkle). Verificable a
3//! posteriori sin confianza en quien escribe.
4//!
5//! Los entries viven en memoria. Para persistencia, `flush_to_cas()` los
6//! escribe al content-addressable store y devuelve el SHA del head, que
7//! puede guardarse en un archivo de "head pointer" (fuera de scope aquí).
8
9use crate::crystallize::Crystal;
10use serde::{Deserialize, Serialize};
11use std::collections::VecDeque;
12use ulid::Ulid;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AuditEntry {
16    /// Sequence number monotónico desde el inicio del log.
17    pub seq: u64,
18    /// Wall-clock al insertar.
19    pub timestamp_ms: u64,
20    /// SHA256 del entry anterior. None para el primer entry.
21    pub prev_sha: Option<[u8; 32]>,
22    /// SHA256 de este entry (auto-calculado al construir).
23    pub sha: [u8; 32],
24    /// Acción registrada.
25    pub action: AuditAction,
26}
27
28/// Sin `#[serde(tag)]`: bincode requiere external tagging (default serde
29/// para enums) para no usar `deserialize_any`. JSON sigue legible.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub enum AuditAction {
32    PromoteCrystal { rule_id: Ulid, crystal: Crystal },
33    RemoveRule { rule_id: Ulid },
34    LoadRulesFile { path: String, count: usize },
35}
36
37pub struct AuditLog {
38    entries: VecDeque<AuditEntry>,
39    next_seq: u64,
40    /// Cap del log en memoria. Entries más viejos se descartan tras flush.
41    cap: usize,
42    /// Total acumulado de entries flusheadas a CAS.
43    flushed_count: u64,
44    /// SHA del último entry persistido a CAS — el "head pointer" del log.
45    last_flushed_sha: Option<[u8; 32]>,
46    /// Path opcional donde escribir el head pointer tras cada flush.
47    head_pointer_path: Option<std::path::PathBuf>,
48}
49
50impl AuditLog {
51    pub fn new() -> Self {
52        Self::with_cap(512)
53    }
54
55    pub fn with_cap(cap: usize) -> Self {
56        Self {
57            entries: VecDeque::new(),
58            next_seq: 0,
59            cap,
60            flushed_count: 0,
61            last_flushed_sha: None,
62            head_pointer_path: None,
63        }
64    }
65
66    pub fn with_head_pointer(mut self, path: std::path::PathBuf) -> Self {
67        self.head_pointer_path = Some(path);
68        self
69    }
70
71    /// Apendea una acción. Calcula el SHA encadenado contra el último entry.
72    pub fn append(&mut self, action: AuditAction) -> AuditEntry {
73        let prev_sha = self.entries.back().map(|e| e.sha);
74        let timestamp_ms = now_ms();
75        let seq = self.next_seq;
76        self.next_seq += 1;
77
78        // Pre-construct con sha en cero, luego calcular sha sobre el
79        // serializado canónico, luego sobreescribir el campo.
80        let mut entry = AuditEntry {
81            seq, timestamp_ms, prev_sha, sha: [0u8; 32], action,
82        };
83        entry.sha = compute_sha(&entry);
84
85        if self.entries.len() >= self.cap {
86            self.entries.pop_front();
87        }
88        self.entries.push_back(entry.clone());
89        entry
90    }
91
92    pub fn recent(&self, limit: usize) -> impl Iterator<Item = &AuditEntry> {
93        let n = if limit == 0 { self.entries.len() } else { limit.min(self.entries.len()) };
94        self.entries.iter().skip(self.entries.len() - n)
95    }
96
97    pub fn len(&self) -> usize { self.entries.len() }
98    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
99
100    pub fn head_sha(&self) -> Option<[u8; 32]> {
101        self.entries.back().map(|e| e.sha)
102    }
103
104    /// Persiste el entry pasado al CAS y devuelve su SHA. Pensado para
105    /// snapshots externos — el log en memoria sigue intacto.
106    pub fn persist_to_cas(entry: &AuditEntry) -> anyhow::Result<[u8; 32]> {
107        let bytes = serde_json::to_vec(entry)?;
108        let sha = ente_cas::store(&bytes)?;
109        Ok(sha)
110    }
111
112    /// Persiste TODOS los entries actuales al CAS y actualiza el head pointer.
113    /// Idempotente: re-flushar dos veces da los mismos SHAs (CAS dedup).
114    /// Devuelve cuántas entries se flushearon en esta pasada.
115    ///
116    /// Forma canónica: serializamos `entry` con `sha = [0; 32]` (formato
117    /// pre-hash). El CAS computa sha256 sobre esos bytes y devuelve un SHA
118    /// que por construcción coincide con `entry.sha` calculado al append.
119    pub fn flush_to_cas(&mut self) -> anyhow::Result<usize> {
120        let mut written = 0;
121        let mut last_sha = self.last_flushed_sha;
122        for entry in &self.entries {
123            if entry.seq < self.flushed_count { continue; }
124            let bytes = canonical_bytes(entry);
125            let sha = ente_cas::store(&bytes)?;
126            debug_assert_eq!(sha, entry.sha,
127                "CAS sha != entry.sha — fórmula canónica rota");
128            last_sha = Some(sha);
129            written += 1;
130        }
131        self.flushed_count += written as u64;
132        self.last_flushed_sha = last_sha;
133        // Persistir head pointer si está configurado.
134        if let (Some(path), Some(sha)) = (&self.head_pointer_path, last_sha) {
135            let pointer = AuditHeadPointer {
136                last_seq: self.next_seq.saturating_sub(1),
137                last_sha: sha,
138                flushed_count: self.flushed_count,
139                timestamp_ms: now_ms(),
140            };
141            let json = serde_json::to_vec_pretty(&pointer)?;
142            // Escritura atómica: tmp + rename
143            let tmp = path.with_extension("tmp");
144            if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
145            std::fs::write(&tmp, json)?;
146            std::fs::rename(&tmp, path)?;
147        }
148        Ok(written)
149    }
150
151    pub fn flushed_count(&self) -> u64 { self.flushed_count }
152    pub fn last_flushed_sha(&self) -> Option<[u8; 32]> { self.last_flushed_sha }
153}
154
155/// Pointer al head del audit log — escrito atómicamente en disco tras cada
156/// flush. Permite verificar la integridad del log sin escanearlo entero:
157/// el cliente lee el head, recupera el blob desde CAS, valida `prev_sha`
158/// recursivamente hasta el genesis.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct AuditHeadPointer {
161    pub last_seq: u64,
162    pub last_sha: [u8; 32],
163    pub flushed_count: u64,
164    pub timestamp_ms: u64,
165}
166
167impl Default for AuditLog {
168    fn default() -> Self { Self::new() }
169}
170
171fn now_ms() -> u64 {
172    std::time::SystemTime::now()
173        .duration_since(std::time::UNIX_EPOCH)
174        .map(|d| d.as_millis() as u64)
175        .unwrap_or(0)
176}
177
178/// SHA256 sobre el entry en forma canónica (sha=[0;32]). Hash y CAS storage
179/// ven los mismos bytes, así que `ente_cas::store(canonical)` devuelve el
180/// mismo SHA que `compute_sha(entry)`.
181fn compute_sha(entry: &AuditEntry) -> [u8; 32] {
182    let bytes = canonical_bytes(entry);
183    ente_cas::sha256_of(&bytes)
184}
185
186/// Forma canónica: el entry serializado JSON con `sha = [0; 32]`.
187/// JSON sin pretty-print es determinístico para nuestros tipos.
188fn canonical_bytes(entry: &AuditEntry) -> Vec<u8> {
189    let canonical = AuditEntry {
190        sha: [0u8; 32],
191        ..entry.clone()
192    };
193    serde_json::to_vec(&canonical).unwrap_or_default()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn chain_links_consecutive_entries() {
202        let mut log = AuditLog::new();
203        let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
204        let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
205        assert!(e1.prev_sha.is_none());
206        assert_eq!(e2.prev_sha, Some(e1.sha));
207        assert_ne!(e1.sha, e2.sha);
208    }
209
210    #[test]
211    fn seq_monotonic() {
212        let mut log = AuditLog::new();
213        let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
214        let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
215        assert_eq!(e2.seq, e1.seq + 1);
216    }
217
218    #[test]
219    fn cap_evicts_oldest() {
220        let mut log = AuditLog::with_cap(3);
221        for _ in 0..5 {
222            log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
223        }
224        assert_eq!(log.len(), 3);
225        // El primer seq superviviente debe ser 2.
226        assert_eq!(log.recent(0).next().unwrap().seq, 2);
227    }
228}