Skip to main content

eliciter_core/
store.rs

1//! The event-log persistence seam (spec §7 / §8).
2//!
3//! [`StateStore`] is the only I/O boundary the kernel knows about: append one
4//! event, replay a session's events, list sessions, check existence. The
5//! filesystem impl writes one JSON event per line to
6//! `<state_dir>/<session_id>.jsonl`.
7
8use std::fs::{self, File, OpenOptions};
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11
12use crate::error::{EngineError, Result};
13use crate::event::Event;
14
15/// Persistence for the per-session append-only event log.
16///
17/// Implementations must preserve append order and survive process restarts.
18pub trait StateStore: Send + Sync {
19    /// Append one event to the session's log, creating it if absent.
20    fn append(&self, session_id: &str, event: &Event) -> Result<()>;
21
22    /// Replay all events for a session, in append order. Returns
23    /// [`EngineError::SessionNotFound`] if the session has never been opened.
24    fn replay(&self, session_id: &str) -> Result<Vec<Event>>;
25
26    /// True if the session has an event log.
27    fn exists(&self, session_id: &str) -> Result<bool>;
28}
29
30/// Reject session ids that are empty or would escape the state dir / collide
31/// with the `.jsonl` layout. The kernel keys every file by `session_id`, so
32/// this is the durability boundary's safety check (spec §8).
33pub fn validate_session_id(session_id: &str) -> Result<()> {
34    if session_id.is_empty() {
35        return Err(EngineError::BadSessionId("empty session id".into()));
36    }
37    // Disallow path separators, control chars, and traversal. Dots are allowed
38    // inside an id, but a leading `.` or any `..` segment is rejected so an id
39    // can never escape the state dir or hide a file.
40    let has_illegal_char = session_id
41        .chars()
42        .any(|c| c == '/' || c == '\\' || c.is_control());
43    if has_illegal_char || session_id.contains("..") || session_id.starts_with('.') {
44        return Err(EngineError::BadSessionId(format!(
45            "illegal characters in session id '{session_id}'"
46        )));
47    }
48    Ok(())
49}
50
51/// Filesystem-backed [`StateStore`] (spec §8). One `.jsonl` file per session
52/// under `state_dir`.
53#[derive(Debug, Clone)]
54pub struct FilesystemStore {
55    state_dir: PathBuf,
56}
57
58impl FilesystemStore {
59    /// Create a store rooted at `state_dir`, creating the directory if needed.
60    pub fn new(state_dir: impl Into<PathBuf>) -> Result<Self> {
61        let state_dir = state_dir.into();
62        fs::create_dir_all(&state_dir)
63            .map_err(|e| EngineError::StoreError(format!("create_dir_all {state_dir:?}: {e}")))?;
64        Ok(Self { state_dir })
65    }
66
67    /// The on-disk path for a session's log.
68    fn path_for(&self, session_id: &str) -> Result<PathBuf> {
69        validate_session_id(session_id)?;
70        Ok(self.state_dir.join(format!("{session_id}.jsonl")))
71    }
72
73    /// The configured state directory.
74    pub fn state_dir(&self) -> &Path {
75        &self.state_dir
76    }
77}
78
79impl StateStore for FilesystemStore {
80    fn append(&self, session_id: &str, event: &Event) -> Result<()> {
81        let path = self.path_for(session_id)?;
82        let line = serde_json::to_string(event)
83            .map_err(|e| EngineError::StoreError(format!("serialize event: {e}")))?;
84        let mut file = OpenOptions::new()
85            .create(true)
86            .append(true)
87            .open(&path)
88            .map_err(|e| EngineError::StoreError(format!("open {path:?}: {e}")))?;
89        writeln!(file, "{line}")
90            .map_err(|e| EngineError::StoreError(format!("write {path:?}: {e}")))?;
91        file.flush()
92            .map_err(|e| EngineError::StoreError(format!("flush {path:?}: {e}")))?;
93        Ok(())
94    }
95
96    fn replay(&self, session_id: &str) -> Result<Vec<Event>> {
97        let path = self.path_for(session_id)?;
98        if !path.exists() {
99            return Err(EngineError::SessionNotFound(session_id.to_string()));
100        }
101        let file = File::open(&path)
102            .map_err(|e| EngineError::StoreError(format!("open {path:?}: {e}")))?;
103        let reader = BufReader::new(file);
104        let mut events = Vec::new();
105        for (idx, line) in reader.lines().enumerate() {
106            let line = line.map_err(|e| EngineError::StoreError(format!("read line: {e}")))?;
107            if line.trim().is_empty() {
108                continue;
109            }
110            let event: Event = serde_json::from_str(&line).map_err(|e| {
111                EngineError::StoreError(format!("parse {path:?} line {}: {e}", idx + 1))
112            })?;
113            events.push(event);
114        }
115        Ok(events)
116    }
117
118    fn exists(&self, session_id: &str) -> Result<bool> {
119        Ok(self.path_for(session_id)?.exists())
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::session::CoverageSchema;
127
128    fn opened(session_id: &str) -> Event {
129        Event::SessionOpened {
130            session_id: session_id.to_string(),
131            coverage_schema: CoverageSchema::default(),
132        }
133    }
134
135    #[test]
136    fn append_then_replay_round_trips() {
137        let dir = tempfile::tempdir().unwrap();
138        let store = FilesystemStore::new(dir.path()).unwrap();
139        let e = opened("s1");
140        store.append("s1", &e).unwrap();
141        let back = store.replay("s1").unwrap();
142        assert_eq!(back, vec![e]);
143    }
144
145    #[test]
146    fn replay_unknown_session_is_not_found() {
147        let dir = tempfile::tempdir().unwrap();
148        let store = FilesystemStore::new(dir.path()).unwrap();
149        let err = store.replay("nope").unwrap_err();
150        assert!(matches!(err, EngineError::SessionNotFound(_)));
151    }
152
153    #[test]
154    fn bad_session_ids_rejected() {
155        for bad in ["", "../escape", "a/b", ".hidden", "with\0nul"] {
156            assert!(validate_session_id(bad).is_err(), "should reject {bad:?}");
157        }
158        for ok in ["s1", "session-2026", "a.b.c", "UUID_1234"] {
159            assert!(validate_session_id(ok).is_ok(), "should accept {ok:?}");
160        }
161    }
162}