1use 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
24pub const OPERATIONAL_JOURNAL_FORMAT_V1: &str = "# appcore-operational-journal-v1";
26const MAX_JOURNAL_RECORD_BYTES: usize = 1024 * 1024;
27static JOURNAL_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "record_type", content = "record", rename_all = "snake_case")]
33pub enum OperationalJournalRecord {
34 Audit(AuditEntry),
36 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
54pub 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 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 pub fn append_audit(&self, entry: AuditEntry) -> RuntimeResult<()> {
122 self.append(OperationalJournalRecord::Audit(entry))
123 }
124
125 pub fn append_event(&self, event: EventEnvelope) -> RuntimeResult<()> {
127 self.append(OperationalJournalRecord::Event(event))
128 }
129
130 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 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 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;