1use 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
14pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "record_type", content = "record", rename_all = "snake_case")]
22pub enum OperationalJournalRecord {
23 Audit(AuditEntry),
25 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
43pub 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 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 pub fn append_audit(&self, entry: AuditEntry) -> RuntimeResult<()> {
111 self.append(OperationalJournalRecord::Audit(entry))
112 }
113
114 pub fn append_event(&self, event: EventEnvelope) -> RuntimeResult<()> {
116 self.append(OperationalJournalRecord::Event(event))
117 }
118
119 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 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 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;