⎇ a4fa42c781d76852355931d82a28e80230855af8
crates/ente-brain/src/audit.rs
History
Who touched what
raw
Audit→CAS, reload rules, time-decay y forma canónica del hash chain
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}