Skip to main content

appcore_core/
operational_journal.rs

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