⎇ d6b8f18b43c9bd9bf7268e48b61392a2d62bb229
crates/ente-brain/src/audit.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Pausa: 11 crates del fractal Ente #0 con cerebro completo
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}