1use std::fs;
6use std::fs::File;
7#[cfg(test)]
8use std::fs::OpenOptions;
9use std::io::Write;
10use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13
14use fs2::FileExt;
15use serde::de::Error as _;
16use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
17use serde::{Deserialize, Serialize};
18use serde_json::{Number, Value};
19use sha2::{Digest, Sha256};
20
21use super::event::ExecutionEvent;
22use super::verify::verify_events;
23use super::verify::{GENESIS, hash_event};
24use super::{ExecutionLedgerError, Result};
25
26mod platform;
27#[cfg(not(unix))]
28mod portable;
29#[cfg(test)]
30use platform::path_parts;
31#[cfg(unix)]
32use platform::{
33 Operation, capture_relative_parent_identity, create_regular_operation, directory_identity,
34 hard_link_operation, open_ledger_for_append_operation, open_nofollow_operation,
35 open_regular_nofollow_operation, operation_stat, relative_sibling, remove_file_operation,
36 unix_open_operation, unix_open_root, unlink_operation, validate_directory_identity,
37 validate_operation_file,
38};
39#[cfg(unix)]
40use platform::{capture_directory_identity, ensure_default_data_root};
41#[cfg(not(unix))]
42use portable::{capture_directory_identity, ensure_default_data_root};
43
44const LEDGER_RECORD_SCHEMA: &str = "lean-ctx.execution-ledger-record.v1";
45const LEDGER_RECORD_KIND: &str = "execution_event";
46const APPEND_JOURNAL_SCHEMA: &str = "lean-ctx.execution-ledger-append-journal.v1";
47const MAX_LEDGER_RECORD_BYTES: usize = 1024 * 1024;
48const MAX_APPEND_JOURNAL_BYTES: usize = MAX_LEDGER_RECORD_BYTES * 2 + 1024;
49
50#[derive(Deserialize, Serialize)]
51#[serde(deny_unknown_fields)]
52struct LedgerRecordV1 {
53 schema: String,
54 kind: String,
55 event: ExecutionEvent,
56 entry_hash: String,
57}
58
59#[derive(Deserialize, Serialize)]
60#[serde(deny_unknown_fields)]
61struct AppendJournalV1 {
62 schema: String,
63 previous_len: u64,
64 previous_sha256: String,
65 record: String,
66 record_sha256: String,
67}
68
69#[cfg(unix)]
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71struct DirectoryIdentity {
72 dev: u64,
73 ino: u64,
74}
75
76#[cfg(not(unix))]
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78struct DirectoryIdentity;
79
80pub fn default_path() -> Option<PathBuf> {
82 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
83 Some(data_dir.join("execution").join("ledger.jsonl"))
84}
85
86#[derive(Debug)]
88pub struct ExecutionLedgerStore {
89 path: PathBuf,
90 trusted_root: PathBuf,
91 relative_path: PathBuf,
92 trusted_root_identity: Option<DirectoryIdentity>,
93 trusted_parent_identity: Arc<Mutex<Option<DirectoryIdentity>>>,
94}
95
96impl Clone for ExecutionLedgerStore {
97 fn clone(&self) -> Self {
98 Self {
99 path: self.path.clone(),
100 trusted_root: self.trusted_root.clone(),
101 relative_path: self.relative_path.clone(),
102 trusted_root_identity: self.trusted_root_identity,
103 trusted_parent_identity: Arc::clone(&self.trusted_parent_identity),
104 }
105 }
106}
107
108impl PartialEq for ExecutionLedgerStore {
109 fn eq(&self, other: &Self) -> bool {
110 self.path == other.path
111 && self.trusted_root == other.trusted_root
112 && self.relative_path == other.relative_path
113 && self.trusted_root_identity == other.trusted_root_identity
114 }
115}
116
117impl Eq for ExecutionLedgerStore {}
118
119impl ExecutionLedgerStore {
120 #[must_use]
128 pub fn new(path: impl Into<PathBuf>) -> Self {
129 let requested = path.into();
130 let path = if requested.is_absolute() {
131 requested
132 } else {
133 std::env::current_dir()
134 .map(|directory| directory.join(&requested))
135 .unwrap_or(requested)
136 };
137 let trusted_root = path
138 .parent()
139 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
140 let relative_path = path
141 .file_name()
142 .map_or_else(|| PathBuf::from(""), PathBuf::from);
143 let trusted_root_identity = capture_directory_identity(&trusted_root).ok();
144 Self {
145 path,
146 trusted_root,
147 relative_path,
148 trusted_root_identity,
149 trusted_parent_identity: Arc::new(Mutex::new(trusted_root_identity)),
150 }
151 }
152
153 pub fn new_verified(
159 trusted_root: impl Into<PathBuf>,
160 relative: impl Into<PathBuf>,
161 ) -> Result<Self> {
162 let trusted_root = trusted_root.into();
163 let relative_path = relative.into();
164 validate_root_path(&trusted_root)?;
165 validate_relative_path(&relative_path)?;
166 if !trusted_root.is_absolute() {
167 return Err(ExecutionLedgerError::InvalidRecord(
168 "execution ledger trusted root must be absolute".to_owned(),
169 ));
170 }
171 #[cfg(unix)]
172 {
173 let trusted_root = normalize_absolute_root_path(&trusted_root)?;
174 let trusted_root_directory = unix_open_root(&trusted_root)?;
175 let trusted_root_identity = directory_identity(&trusted_root_directory)?;
176 let trusted_parent_identity =
177 capture_relative_parent_identity(&trusted_root, &relative_path)?;
178 Ok(Self {
179 path: trusted_root.join(&relative_path),
180 trusted_root,
181 relative_path,
182 trusted_root_identity: Some(trusted_root_identity),
183 trusted_parent_identity: Arc::new(Mutex::new(trusted_parent_identity)),
184 })
185 }
186 #[cfg(not(unix))]
187 {
188 portable::new_verified(trusted_root, relative_path)
189 }
190 }
191
192 pub fn from_default() -> Result<Self> {
194 let data_dir = crate::core::data_dir::lean_ctx_data_dir().map_err(|_| {
195 ExecutionLedgerError::InvalidRecord("data directory unavailable".to_owned())
196 })?;
197 ensure_default_data_root(&data_dir)?;
198 Self::new_verified(data_dir, Path::new("execution/ledger.jsonl"))
199 }
200
201 #[cfg(unix)]
202 fn open_operation(&self, create_parent: bool) -> Result<Operation> {
203 let expected_parent = *self.trusted_parent_identity.lock().map_err(|_| {
204 ExecutionLedgerError::InvalidRecord(
205 "execution ledger identity lock poisoned".to_owned(),
206 )
207 })?;
208 unix_open_operation(
209 &self.trusted_root,
210 &self.relative_path,
211 self.trusted_root_identity,
212 expected_parent,
213 create_parent,
214 )
215 .and_then(|operation| {
216 let identity = directory_identity(&operation.parent)?;
217 let mut expected = self.trusted_parent_identity.lock().map_err(|_| {
218 ExecutionLedgerError::InvalidRecord(
219 "execution ledger identity lock poisoned".to_owned(),
220 )
221 })?;
222 if let Some(previous) = *expected {
223 if previous != identity {
224 return Err(ExecutionLedgerError::InvalidRecord(
225 "execution ledger parent changed while opening".to_owned(),
226 ));
227 }
228 } else {
229 *expected = Some(identity);
230 }
231 Ok(operation)
232 })
233 }
234
235 #[cfg(unix)]
236 fn open_operation_for_read(&self) -> Result<Option<Operation>> {
237 if self.trusted_root_identity.is_some() {
238 validate_directory_identity(&self.trusted_root, &unix_open_root(&self.trusted_root)?)?;
241 }
242 let parent_pinned = *self.trusted_parent_identity.lock().map_err(|_| {
243 ExecutionLedgerError::InvalidRecord(
244 "execution ledger identity lock poisoned".to_owned(),
245 )
246 })?;
247 match self.open_operation(false) {
248 Ok(operation) => Ok(Some(operation)),
249 Err(ExecutionLedgerError::Io(error))
250 if error.kind() == std::io::ErrorKind::NotFound && parent_pinned.is_none() =>
251 {
252 Ok(None)
253 }
254 Err(error) => Err(error),
255 }
256 }
257
258 #[must_use]
260 pub fn path(&self) -> &Path {
261 &self.path
262 }
263
264 pub fn append(&self, event: ExecutionEvent) -> Result<()> {
266 self.append_if_new(event).map(drop)
267 }
268
269 pub(crate) fn append_if_new(&self, event: ExecutionEvent) -> Result<bool> {
271 #[cfg(not(unix))]
272 {
273 portable::append_if_new(self, event)
274 }
275 #[cfg(unix)]
276 {
277 let mut event = event;
278 let operation = self.open_operation(true)?;
279 let mut file = open_ledger_for_append_operation(&operation)?;
280 file.lock_exclusive()?;
281
282 let result = (|| {
283 validate_operation_file(
284 &operation,
285 &self.relative_path,
286 &file,
287 "execution ledger",
288 true,
289 )?;
290 recover_pending_append_operation(&mut file, &operation)?;
291 let events = read_events_from_file(&file)?;
292 if !verify_events(&events)? {
293 return Err(ExecutionLedgerError::InvalidChain(
294 "cannot append to an invalid chain".to_owned(),
295 ));
296 }
297 if let Some(identity) = event.idempotency_key()
298 && let Some(existing) = events
299 .iter()
300 .find(|existing| existing.idempotency_key() == Some(identity))
301 {
302 return if existing.payload_json()? == event.payload_json()? {
303 Ok(false)
304 } else {
305 Err(ExecutionLedgerError::InvalidRecord(
306 "event identity already exists with different payload".to_owned(),
307 ))
308 };
309 }
310
311 let previous_hash = match events.last() {
312 Some(previous) => hash_event(previous)?,
313 None => GENESIS.to_owned(),
314 };
315 let sequence_number = events
316 .last()
317 .map_or(1, ExecutionEvent::sequence_number)
318 .checked_add(u64::from(!events.is_empty()))
319 .ok_or_else(|| {
320 ExecutionLedgerError::InvalidRecord(
321 "execution ledger sequence number overflow".to_owned(),
322 )
323 })?;
324 event.set_chain_fields(sequence_number, previous_hash);
325 let entry_hash = hash_event(&event)?;
326 event.set_entry_hash(entry_hash.clone());
327 let mut candidate = events;
328 candidate.push(event);
329 if !verify_events(&candidate)? {
330 return Err(ExecutionLedgerError::InvalidChain(
331 "event violates execution lifecycle".to_owned(),
332 ));
333 }
334 let event = candidate.pop().expect("candidate contains appended event");
335 let line = serde_json::to_string(&LedgerRecordV1 {
336 schema: LEDGER_RECORD_SCHEMA.to_owned(),
337 kind: LEDGER_RECORD_KIND.to_owned(),
338 event,
339 entry_hash,
340 })?;
341 let (previous_len, previous_sha256) = file_len_and_sha256(&file)?;
342 #[cfg(unix)]
343 write_append_journal_operation(&operation, previous_len, &previous_sha256, &line)?;
344 file.seek(SeekFrom::End(0))?;
345 writeln!(file, "{line}")?;
346 file.flush()?;
347 file.sync_data()?;
348 #[cfg(unix)]
349 operation.sync_parent()?;
350 #[cfg(unix)]
351 validate_operation_file(
352 &operation,
353 &self.relative_path,
354 &file,
355 "execution ledger",
356 true,
357 )?;
358 #[cfg(unix)]
359 clear_append_journal_operation(&operation)?;
360 #[cfg(unix)]
361 validate_operation_file(
362 &operation,
363 &self.relative_path,
364 &file,
365 "execution ledger",
366 true,
367 )?;
368 Ok(true)
369 })();
370
371 let _ = FileExt::unlock(&file);
372 result
373 }
374 }
375
376 pub fn verify_chain(&self) -> Result<bool> {
378 #[cfg(not(unix))]
379 {
380 portable::verify_chain(self)
381 }
382 #[cfg(unix)]
383 {
384 let Some(operation) = self.open_operation_for_read()? else {
385 return Ok(true);
386 };
387 ensure_no_pending_append_operation(&operation)?;
388 let Some(file) = open_regular_nofollow_operation(&operation, &self.relative_path)?
389 else {
390 return Ok(true);
391 };
392 file.lock_shared()?;
393 let result = match ensure_no_pending_append_operation(&operation)
394 .and_then(|()| read_events_from_file(&file))
395 {
396 Ok(events) => validate_operation_file(
397 &operation,
398 &self.relative_path,
399 &file,
400 "execution ledger",
401 true,
402 )
403 .and_then(|()| verify_events(&events)),
404 Err(ExecutionLedgerError::InvalidChain(_)) => Ok(false),
405 Err(error) => Err(error),
406 };
407 let _ = FileExt::unlock(&file);
408 result
409 }
410 }
411
412 pub fn load(&self) -> Result<Vec<ExecutionEvent>> {
414 #[cfg(not(unix))]
415 {
416 portable::load(self, false)
417 }
418 #[cfg(unix)]
419 {
420 let Some(operation) = self.open_operation_for_read()? else {
421 return Ok(Vec::new());
422 };
423 ensure_no_pending_append_operation(&operation)?;
424 let Some(file) = open_regular_nofollow_operation(&operation, &self.relative_path)?
425 else {
426 return Ok(Vec::new());
427 };
428 file.lock_shared()?;
429 let result = ensure_no_pending_append_operation(&operation)
430 .and_then(|()| read_events_from_file(&file))
431 .and_then(|events| {
432 validate_operation_file(
433 &operation,
434 &self.relative_path,
435 &file,
436 "execution ledger",
437 true,
438 )?;
439 Ok(events)
440 });
441 let _ = FileExt::unlock(&file);
442 result
443 }
444 }
445
446 pub fn load_verified(&self) -> Result<Vec<ExecutionEvent>> {
448 #[cfg(not(unix))]
449 {
450 portable::load(self, true)
451 }
452 #[cfg(unix)]
453 {
454 let Some(operation) = self.open_operation_for_read()? else {
455 return Ok(Vec::new());
456 };
457 ensure_no_pending_append_operation(&operation)?;
458 let Some(file) = open_regular_nofollow_operation(&operation, &self.relative_path)?
459 else {
460 return Ok(Vec::new());
461 };
462 file.lock_shared()?;
463 let result = ensure_no_pending_append_operation(&operation)
464 .and_then(|()| read_events_from_file(&file))
465 .and_then(|events| {
466 validate_operation_file(
467 &operation,
468 &self.relative_path,
469 &file,
470 "execution ledger",
471 true,
472 )?;
473 if verify_events(&events)? {
474 Ok(events)
475 } else {
476 Err(ExecutionLedgerError::InvalidChain(
477 "execution ledger chain or lifecycle is invalid".to_owned(),
478 ))
479 }
480 });
481 let _ = FileExt::unlock(&file);
482 result
483 }
484 }
485
486 pub fn by_task_verified(&self, task_id: &str) -> Result<Vec<ExecutionEvent>> {
488 Ok(self
489 .load_verified()?
490 .into_iter()
491 .filter(|event| event.task_id() == task_id)
492 .collect())
493 }
494
495 #[must_use]
497 pub fn by_task(&self, task_id: &str) -> Vec<ExecutionEvent> {
498 self.by_task_verified(task_id).unwrap_or_default()
499 }
500
501 pub fn last_sequence_verified(&self) -> Result<u64> {
503 Ok(self
504 .load_verified()?
505 .last()
506 .map_or(0, ExecutionEvent::sequence_number))
507 }
508
509 #[must_use]
511 pub fn last_sequence(&self) -> u64 {
512 self.last_sequence_verified().unwrap_or(0)
513 }
514}
515
516fn validate_root_path(root: &Path) -> Result<()> {
517 if root.as_os_str().is_empty()
518 || root
519 .components()
520 .any(|component| matches!(component, std::path::Component::ParentDir))
521 {
522 return Err(ExecutionLedgerError::InvalidRecord(
523 "execution ledger trusted root is invalid".to_owned(),
524 ));
525 }
526 Ok(())
527}
528
529#[cfg(unix)]
530fn normalize_absolute_root_path(root: &Path) -> Result<PathBuf> {
531 if !root.is_absolute() {
532 return Err(ExecutionLedgerError::InvalidRecord(
533 "execution ledger trusted root must be absolute".to_owned(),
534 ));
535 }
536 let mut components = root.components();
537 let mut normalized = PathBuf::from(std::path::MAIN_SEPARATOR.to_string());
538 let first = components.next();
539 let first_name = first.and_then(|component| match component {
540 std::path::Component::RootDir => components.next(),
541 _ => None,
542 });
543 if let Some(std::path::Component::Normal(name)) = first_name
544 && (name == "var" || name == "tmp")
545 {
546 let alias = Path::new("/").join(name);
547 if let Ok(target) = fs::read_link(alias) {
548 if target.is_absolute() {
549 normalized = target;
550 } else {
551 normalized.push(target);
552 }
553 } else {
554 normalized.push(name);
555 }
556 } else if let Some(std::path::Component::Normal(name)) = first_name {
557 normalized.push(name);
558 }
559 for component in components {
560 match component {
561 std::path::Component::Normal(name) => normalized.push(name),
562 std::path::Component::CurDir | std::path::Component::RootDir => {}
563 std::path::Component::ParentDir | std::path::Component::Prefix(_) => {
564 return Err(ExecutionLedgerError::InvalidRecord(
565 "execution ledger trusted root is invalid".to_owned(),
566 ));
567 }
568 }
569 }
570 Ok(normalized)
571}
572
573fn validate_relative_path(relative: &Path) -> Result<()> {
574 if relative.as_os_str().is_empty()
575 || relative.is_absolute()
576 || relative.components().any(|component| {
577 matches!(
578 component,
579 std::path::Component::ParentDir
580 | std::path::Component::RootDir
581 | std::path::Component::Prefix(_)
582 )
583 })
584 || !relative
585 .components()
586 .any(|component| matches!(component, std::path::Component::Normal(_)))
587 {
588 return Err(ExecutionLedgerError::InvalidRecord(
589 "execution ledger relative path is invalid".to_owned(),
590 ));
591 }
592 Ok(())
593}
594
595fn read_events_from_file(file: &File) -> Result<Vec<ExecutionEvent>> {
596 let mut source = file.try_clone()?;
597 source.seek(SeekFrom::Start(0))?;
598 let (events, tail) = read_complete_events_and_tail(source)?;
599 if !tail.is_empty() {
600 return Err(ExecutionLedgerError::InvalidRecord(
601 "execution ledger has an unterminated final record".to_owned(),
602 ));
603 }
604 Ok(events)
605}
606
607#[cfg(unix)]
608fn ensure_no_pending_append_operation(operation: &Operation) -> Result<()> {
609 for suffix in [".append-journal", ".append-journal.tmp"] {
610 let pending = relative_sibling(&operation.relative, suffix)?;
611 if open_nofollow_operation(operation, &pending)?.is_some() {
612 return Err(ExecutionLedgerError::InvalidRecord(
613 "execution ledger has a pending append journal".to_owned(),
614 ));
615 }
616 }
617 Ok(())
618}
619
620fn read_complete_events_and_tail(source: impl Read) -> Result<(Vec<ExecutionEvent>, Vec<u8>)> {
621 let mut reader = BufReader::new(source);
622 let mut events = Vec::new();
623 let mut line_number = 0;
624 loop {
625 let Some((mut line, terminated)) = read_bounded_line(&mut reader)? else {
626 return Ok((events, Vec::new()));
627 };
628 if !terminated {
629 return Ok((events, line));
630 }
631 line.pop();
632 let line = std::str::from_utf8(&line).map_err(|_| {
633 ExecutionLedgerError::InvalidRecord(format!(
634 "ledger record at index {line_number} is not UTF-8"
635 ))
636 })?;
637 if line.trim().is_empty() {
638 return Err(ExecutionLedgerError::InvalidRecord(format!(
639 "empty line at index {line_number}"
640 )));
641 }
642 let event = parse_canonical_event(line)?;
643 events.push(event);
644 line_number += 1;
645 }
646}
647
648fn read_bounded_line(reader: &mut impl BufRead) -> Result<Option<(Vec<u8>, bool)>> {
649 let mut line = Vec::new();
650 loop {
651 let buffer = reader.fill_buf()?;
652 if buffer.is_empty() {
653 return if line.is_empty() {
654 Ok(None)
655 } else {
656 Ok(Some((line, false)))
657 };
658 }
659 let newline = buffer.iter().position(|byte| *byte == b'\n');
660 let consumed = newline.map_or(buffer.len(), |index| index + 1);
661 if line.len().saturating_add(consumed) > MAX_LEDGER_RECORD_BYTES + 1 {
662 return Err(ExecutionLedgerError::InvalidRecord(
663 "execution ledger record exceeds byte limit".to_owned(),
664 ));
665 }
666 line.extend_from_slice(&buffer[..consumed]);
667 reader.consume(consumed);
668 if newline.is_some() {
669 return Ok(Some((line, true)));
670 }
671 }
672}
673
674#[cfg(unix)]
675fn recover_pending_append_operation(file: &mut File, operation: &Operation) -> Result<()> {
676 let Some(journal) = read_append_journal_operation(operation)? else {
677 if !file_is_newline_terminated(file)? {
678 return Err(ExecutionLedgerError::InvalidRecord(
679 "unterminated ledger tail has no matching append journal".to_owned(),
680 ));
681 }
682 let temporary = relative_sibling(&operation.relative, ".append-journal.tmp")?;
683 if remove_file_operation(operation, &temporary)? {
684 operation.sync_parent()?;
685 }
686 return Ok(());
687 };
688
689 let current_len = file.metadata()?.len();
690 if current_len < journal.previous_len {
691 return Err(ExecutionLedgerError::InvalidRecord(
692 "ledger is shorter than append journal predecessor".to_owned(),
693 ));
694 }
695 if sha256_prefix(file, journal.previous_len)? != journal.previous_sha256 {
696 return Err(ExecutionLedgerError::InvalidRecord(
697 "ledger predecessor does not match append journal".to_owned(),
698 ));
699 }
700 let mut prefix = file.try_clone()?;
701 prefix.seek(SeekFrom::Start(0))?;
702 let (mut previous_events, tail) =
703 read_complete_events_and_tail(prefix.take(journal.previous_len))?;
704 if !tail.is_empty() {
705 return Err(ExecutionLedgerError::InvalidRecord(
706 "append journal predecessor is not newline-terminated".to_owned(),
707 ));
708 }
709 if !verify_events(&previous_events)? {
710 return Err(ExecutionLedgerError::InvalidChain(
711 "append journal predecessor chain is invalid".to_owned(),
712 ));
713 }
714 previous_events.push(parse_canonical_event(&journal.record)?);
715 if !verify_events(&previous_events)? {
716 return Err(ExecutionLedgerError::InvalidChain(
717 "prepared append violates execution lifecycle".to_owned(),
718 ));
719 }
720 let mut expected = journal.record.into_bytes();
721 expected.push(b'\n');
722 let suffix_len = current_len - journal.previous_len;
723 let expected_len = u64::try_from(expected.len()).map_err(|_| {
724 ExecutionLedgerError::InvalidRecord("prepared append exceeds platform".to_owned())
725 })?;
726 if suffix_len > expected_len {
727 return Err(ExecutionLedgerError::InvalidRecord(
728 "ledger contains bytes after prepared append".to_owned(),
729 ));
730 }
731 let suffix_len = usize::try_from(suffix_len).map_err(|_| {
732 ExecutionLedgerError::InvalidRecord("prepared ledger suffix exceeds platform".to_owned())
733 })?;
734 let mut suffix = vec![0; suffix_len];
735 let mut suffix_reader = file.try_clone()?;
736 suffix_reader.seek(SeekFrom::Start(journal.previous_len))?;
737 suffix_reader.read_exact(&mut suffix)?;
738 if !expected.starts_with(&suffix) {
739 return Err(ExecutionLedgerError::InvalidRecord(
740 "ledger tail does not match prepared append".to_owned(),
741 ));
742 }
743 if suffix_len < expected.len() {
744 file.seek(SeekFrom::End(0))?;
745 file.write_all(&expected[suffix_len..])?;
746 }
747 file.flush()?;
748 file.sync_data()?;
749 operation.sync_parent()?;
750 validate_operation_file(
751 operation,
752 &operation.relative,
753 file,
754 "execution ledger",
755 true,
756 )?;
757 if !verify_events(&read_events_from_file(file)?)? {
758 return Err(ExecutionLedgerError::InvalidChain(
759 "completed prepared append is invalid".to_owned(),
760 ));
761 }
762 clear_append_journal_operation(operation)
763}
764
765fn file_is_newline_terminated(file: &File) -> Result<bool> {
766 let len = file.metadata()?.len();
767 if len == 0 {
768 return Ok(true);
769 }
770 let mut reader = file.try_clone()?;
771 reader.seek(SeekFrom::Start(len - 1))?;
772 let mut last = [0];
773 reader.read_exact(&mut last)?;
774 Ok(last[0] == b'\n')
775}
776
777fn file_len_and_sha256(file: &File) -> Result<(u64, String)> {
778 let len = file.metadata()?.len();
779 Ok((len, sha256_prefix(file, len)?))
780}
781
782fn sha256_prefix(file: &File, len: u64) -> Result<String> {
783 let mut reader = file.try_clone()?;
784 reader.seek(SeekFrom::Start(0))?;
785 let mut remaining = len;
786 let mut digest = Sha256::new();
787 let mut buffer = [0_u8; 16 * 1024];
788 while remaining > 0 {
789 let limit = usize::try_from(remaining)
790 .unwrap_or(buffer.len())
791 .min(buffer.len());
792 let read = reader.read(&mut buffer[..limit])?;
793 if read == 0 {
794 return Err(ExecutionLedgerError::InvalidRecord(
795 "ledger ended before append journal predecessor".to_owned(),
796 ));
797 }
798 digest.update(&buffer[..read]);
799 remaining -= u64::try_from(read).map_err(|_| {
800 ExecutionLedgerError::InvalidRecord("ledger read length overflow".to_owned())
801 })?;
802 }
803 Ok(encode_sha256(digest.finalize()))
804}
805
806#[cfg(test)]
807fn append_journal_path(path: &Path) -> PathBuf {
808 let mut journal = path.as_os_str().to_os_string();
809 journal.push(".append-journal");
810 PathBuf::from(journal)
811}
812
813#[cfg(test)]
814fn append_journal_temp_path(path: &Path) -> PathBuf {
815 let mut temporary = path.as_os_str().to_os_string();
816 temporary.push(".append-journal.tmp");
817 PathBuf::from(temporary)
818}
819
820#[cfg(test)]
821fn write_append_journal(
822 path: &Path,
823 previous_len: u64,
824 previous_sha256: &str,
825 record: &str,
826) -> Result<()> {
827 let (root, relative) = path_parts(path)?;
828 #[cfg(unix)]
829 {
830 let root_identity = capture_directory_identity(&root)?;
831 let parent_identity = capture_relative_parent_identity(&root, &relative)?;
832 let operation = unix_open_operation(
833 &root,
834 &relative,
835 Some(root_identity),
836 parent_identity,
837 false,
838 )?;
839 write_append_journal_operation(&operation, previous_len, previous_sha256, record)
840 }
841 #[cfg(not(unix))]
842 {
843 portable::write_append_journal_path(
844 &root.join(relative),
845 previous_len,
846 previous_sha256,
847 record,
848 )
849 }
850}
851
852#[cfg(unix)]
853fn write_append_journal_operation(
854 operation: &Operation,
855 previous_len: u64,
856 previous_sha256: &str,
857 record: &str,
858) -> Result<()> {
859 if record.len() > MAX_LEDGER_RECORD_BYTES {
860 return Err(ExecutionLedgerError::InvalidRecord(
861 "execution ledger record exceeds byte limit".to_owned(),
862 ));
863 }
864 let journal = AppendJournalV1 {
865 schema: APPEND_JOURNAL_SCHEMA.to_owned(),
866 previous_len,
867 previous_sha256: previous_sha256.to_owned(),
868 record: record.to_owned(),
869 record_sha256: sha256(record.as_bytes()),
870 };
871 let bytes = serde_json::to_vec(&journal)?;
872 if bytes.len() > MAX_APPEND_JOURNAL_BYTES {
873 return Err(ExecutionLedgerError::InvalidRecord(
874 "append journal exceeds byte limit".to_owned(),
875 ));
876 }
877 let temporary = relative_sibling(&operation.relative, ".append-journal.tmp")?;
878 let journal = relative_sibling(&operation.relative, ".append-journal")?;
879 if remove_file_operation(operation, &temporary)? {
880 operation.sync_parent()?;
881 }
882 let mut file = create_regular_operation(operation, &temporary, "append journal temporary")?;
883 let mut published = false;
884 let result = (|| {
885 operation.validate_paths()?;
886 file.write_all(&bytes)?;
887 file.flush()?;
888 file.sync_all()?;
889 validate_operation_file(
890 operation,
891 &temporary,
892 &file,
893 "append journal temporary",
894 true,
895 )?;
896 hard_link_operation(operation, &temporary, &journal)?;
897 published = true;
898 operation.sync_parent()?;
899 let journal_file = open_nofollow_operation(operation, &journal)?.ok_or_else(|| {
900 ExecutionLedgerError::InvalidRecord("published append journal disappeared".to_owned())
901 })?;
902 validate_journal_link_pair_operation(
903 operation,
904 &journal,
905 &temporary,
906 &journal_file,
907 &file,
908 )?;
909 unlink_operation(operation, &temporary)?;
910 operation.sync_parent()
911 })();
912 if result.is_err() {
913 if published {
914 let _ = clear_append_journal_operation(operation);
915 } else {
916 let _ = remove_file_operation(operation, &temporary);
917 }
918 }
919 result
920}
921
922fn read_bounded_file(mut file: File, max_bytes: usize, label: &str) -> Result<Vec<u8>> {
923 let max_len = u64::try_from(max_bytes).map_err(|_| {
924 ExecutionLedgerError::InvalidRecord(format!("{label} byte limit exceeds platform"))
925 })?;
926 if file.metadata()?.len() > max_len {
927 return Err(ExecutionLedgerError::InvalidRecord(format!(
928 "{label} exceeds byte limit"
929 )));
930 }
931 let capacity = usize::try_from(file.metadata()?.len()).unwrap_or(max_bytes);
932 let mut bytes = Vec::with_capacity(capacity.min(max_bytes));
933 std::io::Read::by_ref(&mut file)
934 .take(max_len.saturating_add(1))
935 .read_to_end(&mut bytes)?;
936 if bytes.len() > max_bytes {
937 return Err(ExecutionLedgerError::InvalidRecord(format!(
938 "{label} exceeds byte limit"
939 )));
940 }
941 Ok(bytes)
942}
943
944#[cfg(unix)]
945fn validate_journal_link_pair_operation(
946 operation: &Operation,
947 journal_path: &Path,
948 temporary_path: &Path,
949 journal: &File,
950 temporary: &File,
951) -> Result<()> {
952 use std::os::unix::fs::MetadataExt;
953 let journal_metadata = journal.metadata()?;
954 let temporary_metadata = temporary.metadata()?;
955 let same_inode = journal_metadata.dev() == temporary_metadata.dev()
956 && journal_metadata.ino() == temporary_metadata.ino();
957 if !journal_metadata.is_file()
958 || !temporary_metadata.is_file()
959 || journal_metadata.nlink() != 2
960 || temporary_metadata.nlink() != 2
961 || !same_inode
962 {
963 return Err(ExecutionLedgerError::InvalidRecord(
964 "append journal link pair identity is invalid".to_owned(),
965 ));
966 }
967 let journal_stat = operation_stat(operation, journal_path)?;
968 let temporary_stat = operation_stat(operation, temporary_path)?;
969 if journal_stat.st_mode as libc::mode_t & libc::S_IFMT != libc::S_IFREG
970 || temporary_stat.st_mode as libc::mode_t & libc::S_IFMT != libc::S_IFREG
971 || journal_stat.st_nlink as u64 != 2
972 || temporary_stat.st_nlink as u64 != 2
973 || journal_stat.st_dev as u64 != journal_metadata.dev()
974 || journal_stat.st_ino as u64 != journal_metadata.ino()
975 || temporary_stat.st_dev as u64 != journal_metadata.dev()
976 || temporary_stat.st_ino as u64 != journal_metadata.ino()
977 {
978 return Err(ExecutionLedgerError::InvalidRecord(
979 "append journal link pair changed while reading".to_owned(),
980 ));
981 }
982 Ok(())
983}
984
985#[cfg(unix)]
986fn read_append_journal_operation(operation: &Operation) -> Result<Option<AppendJournalV1>> {
987 let journal = relative_sibling(&operation.relative, ".append-journal")?;
988 let temporary = relative_sibling(&operation.relative, ".append-journal.tmp")?;
989 let Some(file) = open_nofollow_operation(operation, &journal)? else {
990 return Ok(None);
991 };
992 let bytes = read_bounded_file(
993 file.try_clone()?,
994 MAX_APPEND_JOURNAL_BYTES,
995 "append journal",
996 )?;
997 use std::os::unix::fs::MetadataExt;
998 if file.metadata()?.nlink() == 2 {
999 let Some(temp_file) = open_nofollow_operation(operation, &temporary)? else {
1000 return Err(ExecutionLedgerError::InvalidRecord(
1001 "append journal link pair is incomplete".to_owned(),
1002 ));
1003 };
1004 let temp_bytes = read_bounded_file(
1005 temp_file.try_clone()?,
1006 MAX_APPEND_JOURNAL_BYTES,
1007 "append journal temporary",
1008 )?;
1009 if bytes != temp_bytes {
1010 return Err(ExecutionLedgerError::InvalidRecord(
1011 "append journal link pair content differs".to_owned(),
1012 ));
1013 }
1014 validate_journal_link_pair_operation(operation, &journal, &temporary, &file, &temp_file)?;
1015 } else {
1016 validate_operation_file(operation, &journal, &file, "append journal", true)?;
1017 }
1018 let text = std::str::from_utf8(&bytes).map_err(|_| {
1019 ExecutionLedgerError::InvalidRecord("append journal is not UTF-8".to_owned())
1020 })?;
1021 let mut deserializer = serde_json::Deserializer::from_str(text);
1022 let value = StrictJson::deserialize(&mut deserializer)
1023 .map_err(serde_json::Error::custom)?
1024 .into_value();
1025 deserializer.end()?;
1026 let journal: AppendJournalV1 = serde_json::from_value(value)?;
1027 if journal.schema != APPEND_JOURNAL_SCHEMA
1028 || serde_json::to_vec(&journal)? != bytes
1029 || journal.record.len() > MAX_LEDGER_RECORD_BYTES
1030 || sha256(journal.record.as_bytes()) != journal.record_sha256
1031 || parse_canonical_event(&journal.record).is_err()
1032 {
1033 return Err(ExecutionLedgerError::InvalidRecord(
1034 "append journal is invalid or non-canonical".to_owned(),
1035 ));
1036 }
1037 Ok(Some(journal))
1038}
1039
1040#[cfg(unix)]
1041fn clear_append_journal_operation(operation: &Operation) -> Result<()> {
1042 let journal = relative_sibling(&operation.relative, ".append-journal")?;
1043 let temporary = relative_sibling(&operation.relative, ".append-journal.tmp")?;
1044 if let Some(journal_file) = open_nofollow_operation(operation, &journal)? {
1045 use std::os::unix::fs::MetadataExt;
1046 if journal_file.metadata()?.nlink() == 2 {
1047 let temporary_file =
1048 open_nofollow_operation(operation, &temporary)?.ok_or_else(|| {
1049 ExecutionLedgerError::InvalidRecord(
1050 "append journal link pair is incomplete".to_owned(),
1051 )
1052 })?;
1053 validate_journal_link_pair_operation(
1054 operation,
1055 &journal,
1056 &temporary,
1057 &journal_file,
1058 &temporary_file,
1059 )?;
1060 unlink_operation(operation, &journal)?;
1061 unlink_operation(operation, &temporary)?;
1062 } else {
1063 remove_file_operation(operation, &journal)?;
1064 let _ = remove_file_operation(operation, &temporary)?;
1065 }
1066 } else {
1067 let _ = remove_file_operation(operation, &temporary)?;
1068 }
1069 operation.sync_parent()
1070}
1071
1072fn sha256(bytes: &[u8]) -> String {
1073 encode_sha256(Sha256::digest(bytes))
1074}
1075
1076fn encode_sha256(digest: impl AsRef<[u8]>) -> String {
1077 const HEX: &[u8; 16] = b"0123456789abcdef";
1078 let mut encoded = String::with_capacity(71);
1079 encoded.push_str("sha256:");
1080 for byte in digest.as_ref() {
1081 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
1082 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
1083 }
1084 encoded
1085}
1086
1087enum StrictJson {
1088 Null,
1089 Bool(bool),
1090 Number(Number),
1091 String(String),
1092 Array(Vec<Self>),
1093 Object(serde_json::Map<String, Value>),
1094}
1095
1096impl StrictJson {
1097 fn into_value(self) -> Value {
1098 match self {
1099 Self::Null => Value::Null,
1100 Self::Bool(value) => Value::Bool(value),
1101 Self::Number(value) => Value::Number(value),
1102 Self::String(value) => Value::String(value),
1103 Self::Array(values) => Value::Array(values.into_iter().map(Self::into_value).collect()),
1104 Self::Object(value) => Value::Object(value),
1105 }
1106 }
1107}
1108
1109impl<'de> Deserialize<'de> for StrictJson {
1110 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1111 where
1112 D: Deserializer<'de>,
1113 {
1114 struct StrictVisitor;
1115
1116 impl<'de> Visitor<'de> for StrictVisitor {
1117 type Value = StrictJson;
1118
1119 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1120 formatter.write_str("strict JSON without duplicate keys or floats")
1121 }
1122
1123 fn visit_unit<E>(self) -> std::result::Result<Self::Value, E> {
1124 Ok(StrictJson::Null)
1125 }
1126
1127 fn visit_none<E>(self) -> std::result::Result<Self::Value, E> {
1128 Ok(StrictJson::Null)
1129 }
1130
1131 fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E> {
1132 Ok(StrictJson::Bool(value))
1133 }
1134
1135 fn visit_i64<E>(self, value: i64) -> std::result::Result<Self::Value, E> {
1136 Ok(StrictJson::Number(Number::from(value)))
1137 }
1138
1139 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
1140 Ok(StrictJson::Number(Number::from(value)))
1141 }
1142
1143 fn visit_f64<E>(self, _value: f64) -> std::result::Result<Self::Value, E>
1144 where
1145 E: de::Error,
1146 {
1147 Err(E::custom("floating-point ledger numbers are forbidden"))
1148 }
1149
1150 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
1151 Ok(StrictJson::String(value.to_owned()))
1152 }
1153
1154 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
1155 Ok(StrictJson::String(value))
1156 }
1157
1158 fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error>
1159 where
1160 A: SeqAccess<'de>,
1161 {
1162 let mut values = Vec::new();
1163 while let Some(value) = sequence.next_element::<StrictJson>()? {
1164 values.push(value);
1165 }
1166 Ok(StrictJson::Array(values))
1167 }
1168
1169 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
1170 where
1171 A: MapAccess<'de>,
1172 {
1173 let mut values = serde_json::Map::new();
1174 while let Some(key) = map.next_key::<String>()? {
1175 if values.contains_key(&key) {
1176 return Err(de::Error::custom(format!(
1177 "duplicate ledger JSON key '{key}'"
1178 )));
1179 }
1180 values.insert(key, map.next_value::<StrictJson>()?.into_value());
1181 }
1182 Ok(StrictJson::Object(values))
1183 }
1184 }
1185
1186 deserializer.deserialize_any(StrictVisitor)
1187 }
1188}
1189
1190fn parse_canonical_event(line: &str) -> Result<ExecutionEvent> {
1191 let mut deserializer = serde_json::Deserializer::from_str(line);
1192 let value = StrictJson::deserialize(&mut deserializer)
1193 .map_err(serde_json::Error::custom)?
1194 .into_value();
1195 deserializer.end()?;
1196 let record: LedgerRecordV1 = serde_json::from_value(value)?;
1197 if record.schema != LEDGER_RECORD_SCHEMA || record.kind != LEDGER_RECORD_KIND {
1198 return Err(ExecutionLedgerError::InvalidRecord(
1199 "unsupported execution ledger record schema or kind".to_owned(),
1200 ));
1201 }
1202 if serde_json::to_string(&record)? != line {
1203 return Err(ExecutionLedgerError::InvalidRecord(
1204 "execution ledger record is not canonical JSON".to_owned(),
1205 ));
1206 }
1207 if hash_event(&record.event)? != record.entry_hash {
1208 return Err(ExecutionLedgerError::InvalidChain(
1209 "execution ledger record digest mismatch".to_owned(),
1210 ));
1211 }
1212 Ok(record.event)
1213}
1214
1215#[cfg(test)]
1216mod tests;