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
13#[cfg(test)]
14use crate::operational_journal_encoding::encoded_records_bytes;
15use crate::operational_journal_encoding::{
16    append_envelope, encode_records, record_hash, retained_suffix_count, write_header,
17};
18use crate::{AuditEntry, EventEnvelope, RuntimeError, RuntimeResult};
19use fs2::FileExt;
20use parking_lot::Mutex;
21use serde::{Deserialize, Serialize};
22use std::collections::VecDeque;
23use std::fs::{self, File, OpenOptions};
24use std::io::{BufRead, BufReader, Read, Write};
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::Arc;
28
29/// Stable format marker for the operational journal.
30pub const OPERATIONAL_JOURNAL_FORMAT_V1: &str = "# appcore-operational-journal-v1";
31pub(super) const MAX_JOURNAL_RECORD_BYTES: usize = 1024 * 1024;
32const MAX_JOURNAL_ENVELOPE_BYTES: usize = MAX_JOURNAL_RECORD_BYTES + 1024;
33// appcore-norm: allow(global-state) reason: atomic sequence prevents process-local temporary path collisions
34static JOURNAL_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
35
36/// One persisted operational record.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "record_type", content = "record", rename_all = "snake_case")]
39pub enum OperationalJournalRecord {
40    /// Generic Runtime audit entry.
41    Audit(AuditEntry),
42    /// Opaque event envelope emitted by an application command.
43    Event(EventEnvelope),
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47struct JournalEnvelope {
48    sequence: u64,
49    previous_hash: String,
50    hash: String,
51    record: OperationalJournalRecord,
52}
53
54enum LineStatus {
55    Complete,
56    Partial,
57    End,
58}
59
60struct JournalState {
61    records: Arc<VecDeque<Arc<OperationalJournalRecord>>>,
62    sequence: u64,
63    last_hash: String,
64}
65
66/// Process-locked, hash-chained operational journal.
67pub struct FileOperationalJournal {
68    path: PathBuf,
69    _lock: File,
70    max_records: usize,
71    max_bytes: u64,
72    state: Mutex<JournalState>,
73}
74
75impl std::fmt::Debug for FileOperationalJournal {
76    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        formatter
78            .debug_struct("FileOperationalJournal")
79            .field("path", &self.path)
80            .field("max_records", &self.max_records)
81            .field("max_bytes", &self.max_bytes)
82            .field("record_count", &self.state.lock().records.len())
83            .finish()
84    }
85}
86
87impl FileOperationalJournal {
88    /// Opens a journal, validates its hash chain, and sanitizes stored audit text.
89    pub fn open(
90        path: impl Into<PathBuf>,
91        max_records: usize,
92        max_bytes: u64,
93    ) -> RuntimeResult<Self> {
94        let path = path.into();
95        let parent = path.parent().unwrap_or_else(|| Path::new("."));
96        fs::create_dir_all(parent).map_err(|error| journal_io("create_parent", error))?;
97        reject_symlink(&path)?;
98        let lock = open_lock(&path.with_extension("journal.lock"))?;
99        lock.try_lock_exclusive()
100            .map_err(|error| journal_io("lock", error))?;
101        if !path.exists() {
102            atomic_replace(&path, write_header)?;
103        }
104        let configured_max_bytes = max_bytes.max(1);
105        let recovery_max_bytes = configured_max_bytes
106            .saturating_add(MAX_JOURNAL_RECORD_BYTES as u64)
107            .saturating_add(64 * 1024);
108        let (state, recovered_tail) = load_state(&path, recovery_max_bytes)?;
109        let journal = Self {
110            path,
111            _lock: lock,
112            max_records: max_records.max(1),
113            max_bytes: configured_max_bytes,
114            state: Mutex::new(state),
115        };
116        let exceeds_limits = {
117            let state = journal.state.lock();
118            state.records.len() > journal.max_records
119                || fs::metadata(&journal.path)
120                    .map(|metadata| metadata.len() > journal.max_bytes)
121                    .unwrap_or(true)
122        };
123        if recovered_tail || exceeds_limits {
124            journal.compact_locked(&mut journal.state.lock())?;
125        }
126        Ok(journal)
127    }
128
129    /// Appends one redacted audit entry.
130    pub fn append_audit(&self, entry: AuditEntry) -> RuntimeResult<()> {
131        self.append(OperationalJournalRecord::Audit(entry.into_bounded()))
132    }
133
134    /// Appends one opaque event envelope.
135    pub fn append_event(&self, event: EventEnvelope) -> RuntimeResult<()> {
136        self.append(OperationalJournalRecord::Event(event))
137    }
138
139    pub(crate) fn append_shared_event(
140        &self,
141        record: Arc<OperationalJournalRecord>,
142    ) -> RuntimeResult<()> {
143        if !matches!(record.as_ref(), OperationalJournalRecord::Event(_)) {
144            return Err(journal_message(
145                "append_event",
146                "shared operational record is not an event".to_string(),
147            ));
148        }
149        self.append_shared(record)
150    }
151
152    pub(crate) fn append_shared_audit(
153        &self,
154        record: Arc<OperationalJournalRecord>,
155    ) -> RuntimeResult<()> {
156        match record.as_ref() {
157            OperationalJournalRecord::Audit(entry) if entry.is_bounded_and_redacted() => {}
158            OperationalJournalRecord::Audit(_) => {
159                return Err(journal_message(
160                    "append_audit",
161                    "shared audit entry is not redacted and bounded".to_string(),
162                ));
163            }
164            OperationalJournalRecord::Event(_) => {
165                return Err(journal_message(
166                    "append_audit",
167                    "shared operational record is not an audit entry".to_string(),
168                ));
169            }
170        }
171        self.append_shared(record)
172    }
173
174    pub(crate) fn shared_audit_records(&self) -> Vec<Arc<OperationalJournalRecord>> {
175        let records = Arc::clone(&self.state.lock().records);
176        records
177            .iter()
178            .filter(|record| matches!(record.as_ref(), OperationalJournalRecord::Audit(_)))
179            .cloned()
180            .collect()
181    }
182
183    pub(crate) fn shared_event_records(&self) -> Vec<Arc<OperationalJournalRecord>> {
184        let records = Arc::clone(&self.state.lock().records);
185        records
186            .iter()
187            .filter(|record| matches!(record.as_ref(), OperationalJournalRecord::Event(_)))
188            .cloned()
189            .collect()
190    }
191
192    /// Returns retained audit entries in journal order.
193    pub fn audit_entries(&self) -> Vec<AuditEntry> {
194        let records = Arc::clone(&self.state.lock().records);
195        records
196            .iter()
197            .filter_map(|record| match record.as_ref() {
198                OperationalJournalRecord::Audit(entry) => Some(entry.clone()),
199                OperationalJournalRecord::Event(_) => None,
200            })
201            .collect()
202    }
203
204    /// Returns retained event envelopes in journal order.
205    pub fn events(&self) -> Vec<EventEnvelope> {
206        let records = Arc::clone(&self.state.lock().records);
207        records
208            .iter()
209            .filter_map(|record| match record.as_ref() {
210                OperationalJournalRecord::Event(event) => Some(event.clone()),
211                OperationalJournalRecord::Audit(_) => None,
212            })
213            .collect()
214    }
215
216    /// Exports retained audit entries as newline-delimited JSON.
217    pub fn export_audit_jsonl(&self) -> RuntimeResult<String> {
218        let mut output = Vec::new();
219        self.write_audit_jsonl(&mut output)?;
220        String::from_utf8(output)
221            .map_err(|error| journal_message("serialize_export", error.to_string()))
222    }
223
224    /// Writes retained audit entries as newline-delimited JSON without cloning the records.
225    pub fn write_audit_jsonl(&self, writer: &mut impl Write) -> RuntimeResult<()> {
226        let records = Arc::clone(&self.state.lock().records);
227        for record in records.iter() {
228            let OperationalJournalRecord::Audit(entry) = record.as_ref() else {
229                continue;
230            };
231            serde_json::to_writer(&mut *writer, entry)
232                .map_err(|error| journal_message("serialize_export", error.to_string()))?;
233            writer
234                .write_all(b"\n")
235                .map_err(|error| journal_io("write_export", error))?;
236        }
237        Ok(())
238    }
239
240    fn append(&self, record: OperationalJournalRecord) -> RuntimeResult<()> {
241        self.append_shared(Arc::new(record))
242    }
243
244    fn append_shared(&self, record: Arc<OperationalJournalRecord>) -> RuntimeResult<()> {
245        let mut state = self.state.lock();
246        let sequence = state.sequence.saturating_add(1);
247        let hash = record_hash(sequence, &state.last_hash, record.as_ref())?;
248        append_envelope(
249            &self.path,
250            sequence,
251            &state.last_hash,
252            &hash,
253            record.as_ref(),
254        )?;
255        Arc::make_mut(&mut state.records).push_back(record);
256        state.sequence = sequence;
257        state.last_hash = hash;
258        if state.records.len() > self.max_records
259            || fs::metadata(&self.path)
260                .map(|metadata| metadata.len() > self.max_bytes)
261                .unwrap_or(true)
262        {
263            self.compact_locked(&mut state)?;
264        }
265        Ok(())
266    }
267
268    fn compact_locked(&self, state: &mut JournalState) -> RuntimeResult<()> {
269        let records = Arc::make_mut(&mut state.records);
270        while records.len() > self.max_records {
271            records.pop_front();
272        }
273        retain_within_bytes(records, self.max_bytes)?;
274        self.rewrite_locked(state)
275    }
276
277    fn rewrite_locked(&self, state: &mut JournalState) -> RuntimeResult<()> {
278        let (sequence, last_hash) = atomic_replace(&self.path, |file| {
279            encode_records(file, state.records.iter().map(AsRef::as_ref))
280        })?;
281        state.sequence = sequence;
282        state.last_hash = last_hash;
283        Ok(())
284    }
285}
286
287fn load_state(path: &Path, max_bytes: u64) -> RuntimeResult<(JournalState, bool)> {
288    reject_symlink(path)?;
289    let metadata = fs::metadata(path).map_err(|error| journal_io("read_metadata", error))?;
290    if metadata.len() > max_bytes {
291        return Err(journal_message(
292            "validate_size",
293            "journal exceeds size limit".to_string(),
294        ));
295    }
296    let file = File::open(path).map_err(|error| journal_io("open_read", error))?;
297    let mut reader = BufReader::new(file).take(max_bytes.saturating_add(1));
298    let mut line = Vec::new();
299    let header = read_bounded_line(&mut reader, &mut line, OPERATIONAL_JOURNAL_FORMAT_V1.len())?;
300    if !matches!(header, LineStatus::Complete)
301        || line.as_slice() != OPERATIONAL_JOURNAL_FORMAT_V1.as_bytes()
302    {
303        return Err(journal_message(
304            "validate_format",
305            "unsupported operational journal format".to_string(),
306        ));
307    }
308    let mut records = VecDeque::new();
309    let mut sequence = 0u64;
310    let mut last_hash = String::new();
311    let mut sanitized_record = false;
312    let recovered_tail = loop {
313        match read_bounded_line(&mut reader, &mut line, MAX_JOURNAL_ENVELOPE_BYTES)? {
314            LineStatus::End => break false,
315            LineStatus::Partial => break true,
316            LineStatus::Complete if line.iter().all(u8::is_ascii_whitespace) => {}
317            LineStatus::Complete => {
318                let envelope: JournalEnvelope = serde_json::from_slice(&line)
319                    .map_err(|error| journal_message("parse_record", error.to_string()))?;
320                validate_envelope(&envelope, sequence.saturating_add(1), &last_hash)?;
321                sequence = envelope.sequence;
322                last_hash = envelope.hash;
323                let (record, sanitized) = sanitize_loaded_record(envelope.record);
324                sanitized_record |= sanitized;
325                records.push_back(Arc::new(record));
326            }
327        }
328    };
329    if reader.limit() == 0 {
330        return Err(journal_message(
331            "validate_size",
332            "journal exceeds size limit".to_string(),
333        ));
334    }
335    Ok((
336        JournalState {
337            records: Arc::new(records),
338            sequence,
339            last_hash,
340        },
341        recovered_tail || sanitized_record,
342    ))
343}
344
345fn sanitize_loaded_record(record: OperationalJournalRecord) -> (OperationalJournalRecord, bool) {
346    match record {
347        OperationalJournalRecord::Audit(entry) if !entry.is_bounded_and_redacted() => {
348            (OperationalJournalRecord::Audit(entry.into_bounded()), true)
349        }
350        record => (record, false),
351    }
352}
353
354fn validate_envelope(
355    envelope: &JournalEnvelope,
356    expected_sequence: u64,
357    expected_previous: &str,
358) -> RuntimeResult<()> {
359    let expected_hash = record_hash(envelope.sequence, expected_previous, &envelope.record)?;
360    if envelope.sequence != expected_sequence
361        || envelope.previous_hash != expected_previous
362        || envelope.hash != expected_hash
363    {
364        return Err(journal_message(
365            "validate_hash_chain",
366            "operational journal hash chain mismatch".to_string(),
367        ));
368    }
369    Ok(())
370}
371
372fn retain_within_bytes(
373    records: &mut VecDeque<Arc<OperationalJournalRecord>>,
374    max_bytes: u64,
375) -> RuntimeResult<()> {
376    let retained = retained_suffix_count(records, max_bytes)?;
377    if retained < records.len() {
378        records.drain(..records.len() - retained);
379    }
380    Ok(())
381}
382
383fn read_bounded_line<R: BufRead>(
384    reader: &mut R,
385    line: &mut Vec<u8>,
386    max_bytes: usize,
387) -> RuntimeResult<LineStatus> {
388    line.clear();
389    loop {
390        let available = reader
391            .fill_buf()
392            .map_err(|error| journal_io("read", error))?;
393        if available.is_empty() {
394            return Ok(if line.is_empty() {
395                LineStatus::End
396            } else {
397                LineStatus::Partial
398            });
399        }
400        let newline = available.iter().position(|byte| *byte == b'\n');
401        let consumed = newline.map_or(available.len(), |index| index + 1);
402        let body_bytes = newline.unwrap_or(available.len());
403        if line.len().saturating_add(body_bytes) > max_bytes {
404            return Err(journal_message(
405                "validate_record",
406                "record exceeds size limit".to_string(),
407            ));
408        }
409        line.extend_from_slice(&available[..body_bytes]);
410        reader.consume(consumed);
411        if newline.is_some() {
412            return Ok(LineStatus::Complete);
413        }
414    }
415}
416
417fn atomic_replace<T>(
418    path: &Path,
419    write: impl FnOnce(&mut File) -> RuntimeResult<T>,
420) -> RuntimeResult<T> {
421    let parent = path.parent().unwrap_or_else(|| Path::new("."));
422    let temporary = parent.join(format!(
423        ".operational-journal.{}-{}.tmp",
424        std::process::id(),
425        JOURNAL_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
426    ));
427    let result = (|| {
428        let mut file = OpenOptions::new()
429            .create_new(true)
430            .write(true)
431            .open(&temporary)
432            .map_err(|error| journal_io("open_temporary", error))?;
433        set_private_file(&file)?;
434        let output = write(&mut file)?;
435        file.sync_all()
436            .map_err(|error| journal_io("write_temporary", error))?;
437        fs::rename(&temporary, path).map_err(|error| journal_io("replace", error))?;
438        sync_parent(parent)?;
439        Ok(output)
440    })();
441    if result.is_err() {
442        let _ = fs::remove_file(temporary);
443    }
444    result
445}
446
447fn open_lock(path: &Path) -> RuntimeResult<File> {
448    reject_symlink(path)?;
449    let file = OpenOptions::new()
450        .create(true)
451        .truncate(false)
452        .read(true)
453        .write(true)
454        .open(path)
455        .map_err(|error| journal_io("open_lock", error))?;
456    set_private_file(&file)?;
457    Ok(file)
458}
459
460fn reject_symlink(path: &Path) -> RuntimeResult<()> {
461    match fs::symlink_metadata(path) {
462        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(
463            journal_message("validate_path", "journal path is unsafe".to_string()),
464        ),
465        Ok(_) => Ok(()),
466        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
467        Err(error) => Err(journal_io("inspect_path", error)),
468    }
469}
470
471#[cfg(unix)]
472fn set_private_file(file: &File) -> RuntimeResult<()> {
473    use std::os::unix::fs::PermissionsExt;
474    file.set_permissions(fs::Permissions::from_mode(0o600))
475        .map_err(|error| journal_io("set_permissions", error))
476}
477
478#[cfg(not(unix))]
479fn set_private_file(_file: &File) -> RuntimeResult<()> {
480    Ok(())
481}
482
483#[cfg(unix)]
484fn sync_parent(path: &Path) -> RuntimeResult<()> {
485    File::open(path)
486        .and_then(|directory| directory.sync_all())
487        .map_err(|error| journal_io("sync_parent", error))
488}
489
490#[cfg(not(unix))]
491fn sync_parent(_path: &Path) -> RuntimeResult<()> {
492    Ok(())
493}
494
495pub(super) fn journal_io(operation: &'static str, error: std::io::Error) -> RuntimeError {
496    journal_message(operation, error.to_string())
497}
498
499pub(super) fn journal_message(operation: &'static str, message: String) -> RuntimeError {
500    RuntimeError::OperationalJournalIo { operation, message }
501}
502
503#[cfg(test)]
504#[path = "operational_journal_tests.rs"]
505mod tests;