Skip to main content

appcore_core/
operational_journal.rs

1//! Durable bounded journal for generic audit entries and emitted events.
2
3use crate::{AuditEntry, EventEnvelope, RuntimeError, RuntimeResult};
4use fs2::FileExt;
5use parking_lot::Mutex;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::VecDeque;
9use std::fs::{self, File, OpenOptions};
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13
14/// Stable format marker for the operational journal.
15pub const OPERATIONAL_JOURNAL_FORMAT_V1: &str = "# appcore-operational-journal-v1";
16const MAX_JOURNAL_RECORD_BYTES: usize = 1024 * 1024;
17static JOURNAL_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
18
19/// One persisted operational record.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "record_type", content = "record", rename_all = "snake_case")]
22pub enum OperationalJournalRecord {
23    /// Generic Runtime audit entry.
24    Audit(AuditEntry),
25    /// Opaque event envelope emitted by an application command.
26    Event(EventEnvelope),
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30struct JournalEnvelope {
31    sequence: u64,
32    previous_hash: String,
33    hash: String,
34    record: OperationalJournalRecord,
35}
36
37struct JournalState {
38    records: VecDeque<OperationalJournalRecord>,
39    sequence: u64,
40    last_hash: String,
41}
42
43/// Process-locked, hash-chained operational journal.
44pub struct FileOperationalJournal {
45    path: PathBuf,
46    _lock: File,
47    max_records: usize,
48    max_bytes: u64,
49    state: Mutex<JournalState>,
50}
51
52impl std::fmt::Debug for FileOperationalJournal {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        formatter
55            .debug_struct("FileOperationalJournal")
56            .field("path", &self.path)
57            .field("max_records", &self.max_records)
58            .field("max_bytes", &self.max_bytes)
59            .field("record_count", &self.state.lock().records.len())
60            .finish()
61    }
62}
63
64impl FileOperationalJournal {
65    /// Opens a journal and validates its complete hash chain.
66    pub fn open(
67        path: impl Into<PathBuf>,
68        max_records: usize,
69        max_bytes: u64,
70    ) -> RuntimeResult<Self> {
71        let path = path.into();
72        let parent = path.parent().unwrap_or_else(|| Path::new("."));
73        fs::create_dir_all(parent).map_err(|error| journal_io("create_parent", error))?;
74        reject_symlink(&path)?;
75        let lock = open_lock(&path.with_extension("journal.lock"))?;
76        lock.try_lock_exclusive()
77            .map_err(|error| journal_io("lock", error))?;
78        if !path.exists() {
79            atomic_write(
80                &path,
81                format!("{OPERATIONAL_JOURNAL_FORMAT_V1}\n").as_bytes(),
82            )?;
83        }
84        let configured_max_bytes = max_bytes.max(1);
85        let recovery_max_bytes = configured_max_bytes
86            .saturating_add(MAX_JOURNAL_RECORD_BYTES as u64)
87            .saturating_add(64 * 1024);
88        let (state, recovered_tail) = load_state(&path, recovery_max_bytes)?;
89        let journal = Self {
90            path,
91            _lock: lock,
92            max_records: max_records.max(1),
93            max_bytes: configured_max_bytes,
94            state: Mutex::new(state),
95        };
96        let exceeds_limits = {
97            let state = journal.state.lock();
98            state.records.len() > journal.max_records
99                || fs::metadata(&journal.path)
100                    .map(|metadata| metadata.len() > journal.max_bytes)
101                    .unwrap_or(true)
102        };
103        if recovered_tail || exceeds_limits {
104            journal.compact_locked(&mut journal.state.lock())?;
105        }
106        Ok(journal)
107    }
108
109    /// Appends one redacted audit entry.
110    pub fn append_audit(&self, entry: AuditEntry) -> RuntimeResult<()> {
111        self.append(OperationalJournalRecord::Audit(entry))
112    }
113
114    /// Appends one opaque event envelope.
115    pub fn append_event(&self, event: EventEnvelope) -> RuntimeResult<()> {
116        self.append(OperationalJournalRecord::Event(event))
117    }
118
119    /// Returns retained audit entries in journal order.
120    pub fn audit_entries(&self) -> Vec<AuditEntry> {
121        self.state
122            .lock()
123            .records
124            .iter()
125            .filter_map(|record| match record {
126                OperationalJournalRecord::Audit(entry) => Some(entry.clone()),
127                OperationalJournalRecord::Event(_) => None,
128            })
129            .collect()
130    }
131
132    /// Returns retained event envelopes in journal order.
133    pub fn events(&self) -> Vec<EventEnvelope> {
134        self.state
135            .lock()
136            .records
137            .iter()
138            .filter_map(|record| match record {
139                OperationalJournalRecord::Event(event) => Some(event.clone()),
140                OperationalJournalRecord::Audit(_) => None,
141            })
142            .collect()
143    }
144
145    /// Exports retained audit entries as newline-delimited JSON.
146    pub fn export_audit_jsonl(&self) -> RuntimeResult<String> {
147        let mut output = String::new();
148        for entry in self.audit_entries() {
149            output.push_str(
150                &serde_json::to_string(&entry)
151                    .map_err(|error| journal_message("serialize_export", error.to_string()))?,
152            );
153            output.push('\n');
154        }
155        Ok(output)
156    }
157
158    fn append(&self, record: OperationalJournalRecord) -> RuntimeResult<()> {
159        let record_bytes = serde_json::to_vec(&record)
160            .map_err(|error| journal_message("serialize_record", error.to_string()))?;
161        if record_bytes.len() > MAX_JOURNAL_RECORD_BYTES {
162            return Err(journal_message(
163                "validate_record",
164                "record exceeds size limit".to_string(),
165            ));
166        }
167        let mut state = self.state.lock();
168        let sequence = state.sequence.saturating_add(1);
169        let hash = record_hash(sequence, &state.last_hash, &record_bytes);
170        let envelope = JournalEnvelope {
171            sequence,
172            previous_hash: state.last_hash.clone(),
173            hash: hash.clone(),
174            record: record.clone(),
175        };
176        append_envelope(&self.path, &envelope)?;
177        state.records.push_back(record);
178        state.sequence = sequence;
179        state.last_hash = hash;
180        if state.records.len() > self.max_records
181            || fs::metadata(&self.path)
182                .map(|metadata| metadata.len() > self.max_bytes)
183                .unwrap_or(true)
184        {
185            self.compact_locked(&mut state)?;
186        }
187        Ok(())
188    }
189
190    fn compact_locked(&self, state: &mut JournalState) -> RuntimeResult<()> {
191        while state.records.len() > self.max_records {
192            state.records.pop_front();
193        }
194        self.rewrite_locked(state)?;
195        while fs::metadata(&self.path)
196            .map(|metadata| metadata.len() > self.max_bytes)
197            .unwrap_or(false)
198            && state.records.len() > 1
199        {
200            state.records.pop_front();
201            self.rewrite_locked(state)?;
202        }
203        Ok(())
204    }
205
206    fn rewrite_locked(&self, state: &mut JournalState) -> RuntimeResult<()> {
207        let (bytes, sequence, last_hash) = encode_records(&state.records)?;
208        atomic_write(&self.path, &bytes)?;
209        state.sequence = sequence;
210        state.last_hash = last_hash;
211        Ok(())
212    }
213}
214
215fn load_state(path: &Path, max_bytes: u64) -> RuntimeResult<(JournalState, bool)> {
216    let text = read_bounded(path, max_bytes)?;
217    let body = text
218        .strip_prefix(OPERATIONAL_JOURNAL_FORMAT_V1)
219        .and_then(|rest| rest.strip_prefix('\n'))
220        .ok_or_else(|| {
221            journal_message(
222                "validate_format",
223                "unsupported operational journal format".to_string(),
224            )
225        })?;
226    let (complete, recovered_tail) = complete_line_prefix(body);
227    let mut records = VecDeque::new();
228    let mut sequence = 0u64;
229    let mut last_hash = String::new();
230    for line in complete.lines().filter(|line| !line.is_empty()) {
231        let envelope: JournalEnvelope = serde_json::from_str(line)
232            .map_err(|error| journal_message("parse_record", error.to_string()))?;
233        validate_envelope(&envelope, sequence.saturating_add(1), &last_hash)?;
234        sequence = envelope.sequence;
235        last_hash = envelope.hash;
236        records.push_back(envelope.record);
237    }
238    Ok((
239        JournalState {
240            records,
241            sequence,
242            last_hash,
243        },
244        recovered_tail,
245    ))
246}
247
248fn validate_envelope(
249    envelope: &JournalEnvelope,
250    expected_sequence: u64,
251    expected_previous: &str,
252) -> RuntimeResult<()> {
253    let record = serde_json::to_vec(&envelope.record)
254        .map_err(|error| journal_message("serialize_record", error.to_string()))?;
255    let expected_hash = record_hash(envelope.sequence, expected_previous, &record);
256    if envelope.sequence != expected_sequence
257        || envelope.previous_hash != expected_previous
258        || envelope.hash != expected_hash
259    {
260        return Err(journal_message(
261            "validate_hash_chain",
262            "operational journal hash chain mismatch".to_string(),
263        ));
264    }
265    Ok(())
266}
267
268fn encode_records(
269    records: &VecDeque<OperationalJournalRecord>,
270) -> RuntimeResult<(Vec<u8>, u64, String)> {
271    let mut output = format!("{OPERATIONAL_JOURNAL_FORMAT_V1}\n");
272    let mut sequence = 0u64;
273    let mut last_hash = String::new();
274    for record in records {
275        sequence = sequence.saturating_add(1);
276        let bytes = serde_json::to_vec(record)
277            .map_err(|error| journal_message("serialize_record", error.to_string()))?;
278        let hash = record_hash(sequence, &last_hash, &bytes);
279        let envelope = JournalEnvelope {
280            sequence,
281            previous_hash: last_hash,
282            hash: hash.clone(),
283            record: record.clone(),
284        };
285        output.push_str(
286            &serde_json::to_string(&envelope)
287                .map_err(|error| journal_message("serialize_envelope", error.to_string()))?,
288        );
289        output.push('\n');
290        last_hash = hash;
291    }
292    Ok((output.into_bytes(), sequence, last_hash))
293}
294
295fn append_envelope(path: &Path, envelope: &JournalEnvelope) -> RuntimeResult<()> {
296    let line = serde_json::to_string(envelope)
297        .map_err(|error| journal_message("serialize_envelope", error.to_string()))?;
298    let mut file = OpenOptions::new()
299        .append(true)
300        .open(path)
301        .map_err(|error| journal_io("open_append", error))?;
302    writeln!(file, "{line}").map_err(|error| journal_io("append_record", error))?;
303    file.sync_data()
304        .map_err(|error| journal_io("sync_record", error))
305}
306
307fn record_hash(sequence: u64, previous_hash: &str, record: &[u8]) -> String {
308    let mut hasher = Sha256::new();
309    hasher.update(OPERATIONAL_JOURNAL_FORMAT_V1.as_bytes());
310    hasher.update(sequence.to_be_bytes());
311    hasher.update((previous_hash.len() as u64).to_be_bytes());
312    hasher.update(previous_hash.as_bytes());
313    hasher.update((record.len() as u64).to_be_bytes());
314    hasher.update(record);
315    format!("{:x}", hasher.finalize())
316}
317
318fn complete_line_prefix(body: &str) -> (&str, bool) {
319    if body.is_empty() || body.ends_with('\n') {
320        return (body, false);
321    }
322    match body.rfind('\n') {
323        Some(index) => (&body[..=index], true),
324        None => ("", true),
325    }
326}
327
328fn read_bounded(path: &Path, max_bytes: u64) -> RuntimeResult<String> {
329    reject_symlink(path)?;
330    let mut file = File::open(path).map_err(|error| journal_io("open_read", error))?;
331    if file
332        .metadata()
333        .map_err(|error| journal_io("read_metadata", error))?
334        .len()
335        > max_bytes
336    {
337        return Err(journal_message(
338            "validate_size",
339            "journal exceeds size limit".to_string(),
340        ));
341    }
342    let mut text = String::new();
343    Read::by_ref(&mut file)
344        .take(max_bytes.saturating_add(1))
345        .read_to_string(&mut text)
346        .map_err(|error| journal_io("read", error))?;
347    Ok(text)
348}
349
350fn atomic_write(path: &Path, bytes: &[u8]) -> RuntimeResult<()> {
351    let parent = path.parent().unwrap_or_else(|| Path::new("."));
352    let temporary = parent.join(format!(
353        ".operational-journal.{}-{}.tmp",
354        std::process::id(),
355        JOURNAL_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
356    ));
357    let result = (|| {
358        let mut file = OpenOptions::new()
359            .create_new(true)
360            .write(true)
361            .open(&temporary)
362            .map_err(|error| journal_io("open_temporary", error))?;
363        set_private_file(&file)?;
364        file.write_all(bytes)
365            .and_then(|_| file.sync_all())
366            .map_err(|error| journal_io("write_temporary", error))?;
367        fs::rename(&temporary, path).map_err(|error| journal_io("replace", error))?;
368        sync_parent(parent)
369    })();
370    if result.is_err() {
371        let _ = fs::remove_file(temporary);
372    }
373    result
374}
375
376fn open_lock(path: &Path) -> RuntimeResult<File> {
377    reject_symlink(path)?;
378    let file = OpenOptions::new()
379        .create(true)
380        .truncate(false)
381        .read(true)
382        .write(true)
383        .open(path)
384        .map_err(|error| journal_io("open_lock", error))?;
385    set_private_file(&file)?;
386    Ok(file)
387}
388
389fn reject_symlink(path: &Path) -> RuntimeResult<()> {
390    match fs::symlink_metadata(path) {
391        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(
392            journal_message("validate_path", "journal path is unsafe".to_string()),
393        ),
394        Ok(_) => Ok(()),
395        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
396        Err(error) => Err(journal_io("inspect_path", error)),
397    }
398}
399
400#[cfg(unix)]
401fn set_private_file(file: &File) -> RuntimeResult<()> {
402    use std::os::unix::fs::PermissionsExt;
403    file.set_permissions(fs::Permissions::from_mode(0o600))
404        .map_err(|error| journal_io("set_permissions", error))
405}
406
407#[cfg(not(unix))]
408fn set_private_file(_file: &File) -> RuntimeResult<()> {
409    Ok(())
410}
411
412#[cfg(unix)]
413fn sync_parent(path: &Path) -> RuntimeResult<()> {
414    File::open(path)
415        .and_then(|directory| directory.sync_all())
416        .map_err(|error| journal_io("sync_parent", error))
417}
418
419#[cfg(not(unix))]
420fn sync_parent(_path: &Path) -> RuntimeResult<()> {
421    Ok(())
422}
423
424fn journal_io(operation: &'static str, error: std::io::Error) -> RuntimeError {
425    journal_message(operation, error.to_string())
426}
427
428fn journal_message(operation: &'static str, message: String) -> RuntimeError {
429    RuntimeError::OperationalJournalIo { operation, message }
430}
431
432#[cfg(test)]
433#[path = "operational_journal_tests.rs"]
434mod tests;