Skip to main content

immutable_logging/
lib.rs

1//! Immutable Logging - Append-only audit logs with cryptographic proof
2//!
3//! This module implements the immutable audit layer as specified in SPEC_IMMUTABLE_LOGGING.md
4//! Features:
5//! - Chained hash verification
6//! - Hourly Merkle tree roots
7//! - Daily publication
8//! - TSA timestamps
9
10pub mod chain;
11pub mod error;
12pub mod log_entry;
13pub mod merkle_service;
14pub mod publication;
15
16use serde::{Deserialize, Serialize};
17use std::{io::Write, path::Path, path::PathBuf, sync::Arc};
18use tokio::sync::{Mutex, RwLock};
19
20/// Immutable log service
21pub struct ImmutableLog {
22    chain: Arc<RwLock<chain::LogChain>>,
23    merkle: Arc<RwLock<merkle_service::MerkleService>>,
24    wal: Option<Arc<Mutex<WalStore>>>,
25}
26
27struct WalStore {
28    path: PathBuf,
29}
30
31impl WalStore {
32    fn open(path: PathBuf) -> Result<Self, error::LogError> {
33        if let Some(parent) = path.parent() {
34            std::fs::create_dir_all(parent)
35                .map_err(|e| error::LogError::PublicationError(e.to_string()))?;
36        }
37        if !path.exists() {
38            std::fs::File::create(&path)
39                .map_err(|e| error::LogError::PublicationError(e.to_string()))?;
40        }
41        Ok(Self { path })
42    }
43
44    fn load_entries(&self) -> Result<Vec<log_entry::LogEntry>, error::LogError> {
45        let raw = std::fs::read_to_string(&self.path)
46            .map_err(|e| error::LogError::PublicationError(e.to_string()))?;
47        let mut out = Vec::new();
48        for (line_no, line) in raw.lines().enumerate() {
49            let line = line.trim();
50            if line.is_empty() {
51                continue;
52            }
53            let entry = serde_json::from_str::<log_entry::LogEntry>(line).map_err(|e| {
54                error::LogError::PublicationError(format!(
55                    "WAL parse error at line {}: {}",
56                    line_no + 1,
57                    e
58                ))
59            })?;
60            out.push(entry);
61        }
62        Ok(out)
63    }
64
65    fn append_entry(&mut self, entry: &log_entry::LogEntry) -> Result<(), error::LogError> {
66        let mut file = std::fs::OpenOptions::new()
67            .create(true)
68            .append(true)
69            .open(&self.path)
70            .map_err(|e| error::LogError::PublicationError(e.to_string()))?;
71        let encoded = serde_json::to_string(entry)
72            .map_err(|e| error::LogError::SerializationError(e.to_string()))?;
73        writeln!(file, "{}", encoded).map_err(|e| error::LogError::PublicationError(e.to_string()))
74    }
75}
76
77impl ImmutableLog {
78    /// Create new immutable log
79    pub fn new() -> Self {
80        ImmutableLog {
81            chain: Arc::new(RwLock::new(chain::LogChain::new())),
82            merkle: Arc::new(RwLock::new(merkle_service::MerkleService::new())),
83            wal: None,
84        }
85    }
86
87    /// Create immutable log backed by an append-only WAL file and replay existing entries on boot.
88    pub async fn with_wal_path<P: AsRef<Path>>(path: P) -> Result<Self, error::LogError> {
89        let wal = WalStore::open(path.as_ref().to_path_buf())?;
90        let entries = wal.load_entries()?;
91        let log = ImmutableLog {
92            chain: Arc::new(RwLock::new(chain::LogChain::new())),
93            merkle: Arc::new(RwLock::new(merkle_service::MerkleService::new())),
94            wal: Some(Arc::new(Mutex::new(wal))),
95        };
96
97        if !entries.is_empty() {
98            let mut chain = log.chain.write().await;
99            let mut merkle = log.merkle.write().await;
100            for entry in entries {
101                chain.append_committed(entry.clone()).await?;
102                merkle.add_entry(entry).await?;
103            }
104        }
105
106        Ok(log)
107    }
108
109    /// Append a new entry
110    pub async fn append(
111        &self,
112        entry: log_entry::LogEntry,
113    ) -> Result<log_entry::LogEntry, error::LogError> {
114        // Get current chain state
115        let mut chain = self.chain.write().await;
116        let entry = chain.append(entry).await?;
117
118        // Add to merkle tree
119        let mut merkle = self.merkle.write().await;
120        merkle.add_entry(entry.clone()).await?;
121
122        if let Some(wal) = &self.wal {
123            let mut wal = wal.lock().await;
124            wal.append_entry(&entry)?;
125        }
126
127        Ok(entry)
128    }
129
130    /// Verify chain integrity
131    pub async fn verify(&self) -> Result<bool, error::LogError> {
132        let chain = self.chain.read().await;
133        Ok(chain.verify())
134    }
135
136    /// Get number of entries currently stored in the chain.
137    pub async fn entry_count(&self) -> usize {
138        let chain = self.chain.read().await;
139        chain.len()
140    }
141
142    /// Get the current chain hash (or genesis hash if empty).
143    pub async fn current_hash(&self) -> String {
144        let chain = self.chain.read().await;
145        chain.current_hash().to_string()
146    }
147
148    /// Get current hourly root
149    pub async fn get_hourly_root(&self) -> Option<merkle_service::HourlyRoot> {
150        let merkle = self.merkle.read().await;
151        merkle.get_current_root()
152    }
153
154    /// Snapshot hourly roots (published roots + current in-progress hour root if present).
155    pub async fn hourly_roots_snapshot(&self) -> Vec<merkle_service::HourlyRoot> {
156        let merkle = self.merkle.read().await;
157        let mut roots = merkle.get_published_roots().to_vec();
158        if let Some(current) = merkle.get_current_root() {
159            let exists = roots
160                .iter()
161                .any(|r| r.hour == current.hour && r.root_hash == current.root_hash);
162            if !exists {
163                roots.push(current);
164            }
165        }
166        roots
167    }
168
169    /// Generate chain proof for an entry
170    pub async fn get_chain_proof(&self, entry_id: &str) -> Option<chain::ChainProof> {
171        let chain = self.chain.read().await;
172        let entry = chain.get_entry(entry_id)?.clone();
173        let mut proof = chain.generate_proof(entry_id)?;
174        drop(chain);
175
176        let merkle = self.merkle.read().await;
177        let merkle_proof = merkle.generate_proof(entry_id, &entry);
178        proof.attach_merkle_proof(merkle_proof);
179        Some(proof)
180    }
181}
182
183impl Default for ImmutableLog {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189/// Configuration for immutable logging
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct LogConfig {
192    /// Hash algorithm
193    pub hash_algorithm: String,
194    /// Hourly publication enabled
195    pub hourly_publication: bool,
196    /// Daily publication enabled
197    pub daily_publication: bool,
198    /// TSA server URL
199    pub tsa_url: Option<String>,
200    /// Blockchain enabled
201    pub blockchain_enabled: bool,
202}
203
204impl Default for LogConfig {
205    fn default() -> Self {
206        LogConfig {
207            hash_algorithm: "SHA256".to_string(),
208            hourly_publication: true,
209            daily_publication: true,
210            tsa_url: None,
211            blockchain_enabled: true,
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_default_config() {
222        let config = LogConfig::default();
223        assert_eq!(config.hash_algorithm, "SHA256");
224        assert!(config.hourly_publication);
225    }
226
227    #[tokio::test]
228    async fn test_append_entry() {
229        let log = ImmutableLog::new();
230
231        let entry = log_entry::LogEntry::new(
232            log_entry::EventType::AccountQuery,
233            "agent-001".to_string(),
234            "org-001".to_string(),
235        )
236        .unwrap();
237
238        let result = log.append(entry).await;
239        assert!(result.is_ok());
240    }
241
242    #[tokio::test]
243    async fn test_chain_stats() {
244        let log = ImmutableLog::new();
245        assert_eq!(log.entry_count().await, 0);
246        assert_eq!(log.current_hash().await.len(), 64);
247    }
248
249    #[tokio::test]
250    async fn test_hourly_roots_snapshot_includes_current_root() {
251        let log = ImmutableLog::new();
252        let entry = log_entry::LogEntry::new(
253            log_entry::EventType::AccountQuery,
254            "agent-001".to_string(),
255            "org-001".to_string(),
256        )
257        .unwrap();
258        log.append(entry).await.expect("append");
259
260        let roots = log.hourly_roots_snapshot().await;
261        assert_eq!(roots.len(), 1);
262        assert_eq!(roots[0].entry_count, 1);
263    }
264
265    #[tokio::test]
266    async fn test_wal_replay_rebuilds_chain() {
267        let tmp = tempfile::tempdir().expect("tempdir");
268        let wal_path = tmp.path().join("log.wal");
269
270        let log = ImmutableLog::with_wal_path(&wal_path)
271            .await
272            .expect("wal init");
273        let entry = log_entry::LogEntry::new(
274            log_entry::EventType::AccountQuery,
275            "agent-001".to_string(),
276            "org-001".to_string(),
277        )
278        .unwrap();
279        log.append(entry).await.expect("append");
280        assert_eq!(log.entry_count().await, 1);
281        drop(log);
282
283        let reloaded = ImmutableLog::with_wal_path(&wal_path)
284            .await
285            .expect("wal reload");
286        assert_eq!(reloaded.entry_count().await, 1);
287        assert!(reloaded.verify().await.expect("verify"));
288    }
289}