gioser

tawasuyu/arje

Rust 38TOML 12Markdown 1410 Definitions

⎇ badf4257ecd5a4ef06f6f477279834893a24f87b crates/ente-brain/src/audit.rs Rust · 305 lines · 10.9 KB History Who touched what raw

audit-verify, autopromote, histogramas y hot-reload caps badf4257e · 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
167/// Reporte de verificación de la cadena audit.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct VerificationReport {
170    /// Cuántas entries se recorrieron y verificaron exitosamente.
171    pub verified: u64,
172    /// Si hubo error, el seq donde se detectó.
173    pub broken_at_seq: Option<u64>,
174    /// Detalles del error si hubo.
175    pub error: Option<String>,
176    /// SHA del genesis (primer entry; prev_sha = None).
177    pub genesis_sha: Option<[u8; 32]>,
178}
179
180/// Recorre la cadena del audit log desde `start_sha` hacia atrás vía `prev_sha`
181/// hasta el genesis. Para cada entry valida:
182///   1. CAS contiene un blob bajo ese SHA
183///   2. sha256(blob) == SHA esperado (defensa contra tampering del CAS)
184///   3. El blob deserializa a AuditEntry con sha=[0;32] (forma canónica)
185///
186/// Devuelve un VerificationReport con el conteo, posibles errores y
187/// el SHA del genesis (útil para clientes que quieren cachearlo).
188pub fn verify_chain_from_cas(start_sha: [u8; 32]) -> VerificationReport {
189    let mut current = Some(start_sha);
190    let mut verified = 0u64;
191    let mut last_seen: Option<AuditEntry> = None;
192
193    while let Some(sha) = current {
194        let path = ente_cas::cas_root().join(ente_cas::hex(&sha));
195        let bytes = match std::fs::read(&path) {
196            Ok(b) => b,
197            Err(e) => return VerificationReport {
198                verified,
199                broken_at_seq: last_seen.as_ref().map(|e| e.seq),
200                error: Some(format!("CAS read {}: {e}", path.display())),
201                genesis_sha: None,
202            },
203        };
204        // Verificación 1: el blob hashea a la SHA esperada (CAS contract).
205        let actual = ente_cas::sha256_of(&bytes);
206        if actual != sha {
207            return VerificationReport {
208                verified,
209                broken_at_seq: last_seen.as_ref().map(|e| e.seq),
210                error: Some(format!(
211                    "CAS tamper en {}: expected {} got {}",
212                    path.display(), ente_cas::hex(&sha), ente_cas::hex(&actual)
213                )),
214                genesis_sha: None,
215            };
216        }
217        // Verificación 2: deserialize. El blob canónico tiene sha=[0;32].
218        let mut entry: AuditEntry = match serde_json::from_slice(&bytes) {
219            Ok(e) => e,
220            Err(e) => return VerificationReport {
221                verified,
222                broken_at_seq: last_seen.as_ref().map(|e| e.seq),
223                error: Some(format!("deserialize: {e}")),
224                genesis_sha: None,
225            },
226        };
227        // Re-poblar el sha en el entry para reportar coherentemente.
228        entry.sha = sha;
229        verified += 1;
230
231        let prev = entry.prev_sha;
232        last_seen = Some(entry);
233        current = prev;
234    }
235
236    VerificationReport {
237        verified,
238        broken_at_seq: None,
239        error: None,
240        genesis_sha: last_seen.as_ref().map(|e| e.sha),
241    }
242}
243
244impl Default for AuditLog {
245    fn default() -> Self { Self::new() }
246}
247
248fn now_ms() -> u64 {
249    std::time::SystemTime::now()
250        .duration_since(std::time::UNIX_EPOCH)
251        .map(|d| d.as_millis() as u64)
252        .unwrap_or(0)
253}
254
255/// SHA256 sobre el entry en forma canónica (sha=[0;32]). Hash y CAS storage
256/// ven los mismos bytes, así que `ente_cas::store(canonical)` devuelve el
257/// mismo SHA que `compute_sha(entry)`.
258fn compute_sha(entry: &AuditEntry) -> [u8; 32] {
259    let bytes = canonical_bytes(entry);
260    ente_cas::sha256_of(&bytes)
261}
262
263/// Forma canónica: el entry serializado JSON con `sha = [0; 32]`.
264/// JSON sin pretty-print es determinístico para nuestros tipos.
265fn canonical_bytes(entry: &AuditEntry) -> Vec<u8> {
266    let canonical = AuditEntry {
267        sha: [0u8; 32],
268        ..entry.clone()
269    };
270    serde_json::to_vec(&canonical).unwrap_or_default()
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn chain_links_consecutive_entries() {
279        let mut log = AuditLog::new();
280        let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
281        let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
282        assert!(e1.prev_sha.is_none());
283        assert_eq!(e2.prev_sha, Some(e1.sha));
284        assert_ne!(e1.sha, e2.sha);
285    }
286
287    #[test]
288    fn seq_monotonic() {
289        let mut log = AuditLog::new();
290        let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
291        let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
292        assert_eq!(e2.seq, e1.seq + 1);
293    }
294
295    #[test]
296    fn cap_evicts_oldest() {
297        let mut log = AuditLog::with_cap(3);
298        for _ in 0..5 {
299            log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
300        }
301        assert_eq!(log.len(), 3);
302        // El primer seq superviviente debe ser 2.
303        assert_eq!(log.recent(0).next().unwrap().seq, 2);
304    }
305}