Skip to main content

lean_ctx/core/execution_ledger/
store.rs

1//! File-backed append-only execution ledger.
2//! The append journal proves local crash continuity; signed canonical receipts,
3//! not this unkeyed sidecar, provide adversarial authenticity.
4
5use 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
80/// Default execution-ledger location: `<data_dir>/execution/ledger.jsonl`.
81pub 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/// A process-independent handle to an execution ledger file.
87#[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    /// Creates a store backed by `path`.
121    ///
122    /// This compatibility constructor treats the lexical parent of `path` as
123    /// the trusted root.  Every operation still opens each component from a
124    /// descriptor with no-follow semantics; callers that receive an external
125    /// path should prefer [`Self::new_verified`], which makes the root and
126    /// relative path explicit and rejects traversal components up front.
127    #[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    /// Creates a store rooted at `trusted_root` and addressed by `relative`.
154    ///
155    /// The root is never traversed through a symlink and `relative` may not be
156    /// absolute or contain `..`; opened descriptors are checked again after
157    /// acquisition so a concurrent rename cannot redirect a read.
158    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    /// Creates a store at the configured default location.
193    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 the constructor-captured identity before attempting any
239            // descriptor acquisition; a replaced root is never treated as empty.
240            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    /// Returns the backing path.
259    #[must_use]
260    pub fn path(&self) -> &Path {
261        &self.path
262    }
263
264    /// Appends one event, assigning its next sequence number and chain link.
265    pub fn append(&self, event: ExecutionEvent) -> Result<()> {
266        self.append_if_new(event).map(drop)
267    }
268
269    /// Appends one event and reports whether durable state changed.
270    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    /// Verifies the complete on-disk chain under a shared file lock.
377    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    /// Loads all well-formed events in file order.
413    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    /// Loads one snapshot only when its record digests, chain, and lifecycle verify.
447    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    /// Returns verified events associated with `task_id`, propagating unavailable state.
487    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    /// Compatibility projection; exact consumers must use [`Self::by_task_verified`].
496    #[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    /// Returns the verified last sequence number, or zero for an empty/missing file.
502    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    /// Compatibility projection; exact consumers must use [`Self::last_sequence_verified`].
510    #[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;