gioser
⎇ d6b8f18b43c9bd9bf7268e48b61392a2d62bb229 crates/ente-brain/src/audit.rs Rust · 154 líneas · 5.0 KB Historia Quién tocó qué crudo

Pausa: 11 crates del fractal Ente #0 con cerebro completo d6b8f18b4 · 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}
43
44impl AuditLog {
45    pub fn new() -> Self {
46        Self::with_cap(512)
47    }
48
49    pub fn with_cap(cap: usize) -> Self {
50        Self { entries: VecDeque::new(), next_seq: 0, cap }
51    }
52
53    /// Apendea una acción. Calcula el SHA encadenado contra el último entry.
54    pub fn append(&mut self, action: AuditAction) -> AuditEntry {
55        let prev_sha = self.entries.back().map(|e| e.sha);
56        let timestamp_ms = now_ms();
57        let seq = self.next_seq;
58        self.next_seq += 1;
59
60        // Pre-construct con sha en cero, luego calcular sha sobre el
61        // serializado canónico, luego sobreescribir el campo.
62        let mut entry = AuditEntry {
63            seq, timestamp_ms, prev_sha, sha: [0u8; 32], action,
64        };
65        entry.sha = compute_sha(&entry);
66
67        if self.entries.len() >= self.cap {
68            self.entries.pop_front();
69        }
70        self.entries.push_back(entry.clone());
71        entry
72    }
73
74    pub fn recent(&self, limit: usize) -> impl Iterator<Item = &AuditEntry> {
75        let n = if limit == 0 { self.entries.len() } else { limit.min(self.entries.len()) };
76        self.entries.iter().skip(self.entries.len() - n)
77    }
78
79    pub fn len(&self) -> usize { self.entries.len() }
80    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
81
82    pub fn head_sha(&self) -> Option<[u8; 32]> {
83        self.entries.back().map(|e| e.sha)
84    }
85
86    /// Persiste el entry pasado al CAS y devuelve su SHA. Pensado para
87    /// snapshots externos — el log en memoria sigue intacto.
88    pub fn persist_to_cas(entry: &AuditEntry) -> anyhow::Result<[u8; 32]> {
89        let bytes = serde_json::to_vec(entry)?;
90        let sha = ente_cas::store(&bytes)?;
91        Ok(sha)
92    }
93}
94
95impl Default for AuditLog {
96    fn default() -> Self { Self::new() }
97}
98
99fn now_ms() -> u64 {
100    std::time::SystemTime::now()
101        .duration_since(std::time::UNIX_EPOCH)
102        .map(|d| d.as_millis() as u64)
103        .unwrap_or(0)
104}
105
106/// SHA256 sobre el entry serializado, EXCLUYENDO el campo sha mismo
107/// (que está en cero al momento del cálculo). Determinístico vía postcard
108/// para que la verificación sea reproducible.
109fn compute_sha(entry: &AuditEntry) -> [u8; 32] {
110    let bytes = postcard_or_json(entry);
111    ente_cas::sha256_of(&bytes)
112}
113
114fn postcard_or_json(entry: &AuditEntry) -> Vec<u8> {
115    // Preferimos postcard por estabilidad bit-a-bit. Fallback JSON si falla.
116    match postcard::to_stdvec(entry) {
117        Ok(b) => b,
118        Err(_) => serde_json::to_vec(entry).unwrap_or_default(),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn chain_links_consecutive_entries() {
128        let mut log = AuditLog::new();
129        let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
130        let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
131        assert!(e1.prev_sha.is_none());
132        assert_eq!(e2.prev_sha, Some(e1.sha));
133        assert_ne!(e1.sha, e2.sha);
134    }
135
136    #[test]
137    fn seq_monotonic() {
138        let mut log = AuditLog::new();
139        let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
140        let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
141        assert_eq!(e2.seq, e1.seq + 1);
142    }
143
144    #[test]
145    fn cap_evicts_oldest() {
146        let mut log = AuditLog::with_cap(3);
147        for _ in 0..5 {
148            log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
149        }
150        assert_eq!(log.len(), 3);
151        // El primer seq superviviente debe ser 2.
152        assert_eq!(log.recent(0).next().unwrap().seq, 2);
153    }
154}