1#[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
29pub 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;
33static JOURNAL_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "record_type", content = "record", rename_all = "snake_case")]
39pub enum OperationalJournalRecord {
40 Audit(AuditEntry),
42 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
66pub 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 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 pub fn append_audit(&self, entry: AuditEntry) -> RuntimeResult<()> {
131 self.append(OperationalJournalRecord::Audit(entry.into_bounded()))
132 }
133
134 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 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 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 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 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;