1 //! Durable session storage.
2 //!
3 //! One JSON-lines file per session at `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`
4 //! (config dir resolution matches [`crate::settings`] / [`crate::credentials`]:
5 //! `$SIGIT_CONFIG_DIR` or `~/.config/sigit`). Each line is one history message
6 //! as produced by `InferenceBackend::history_snapshot`, so a saved file can be
7 //! restored into either backend.
8 //!
9 //! Writes are atomic (temp file + rename) so a crash mid-save never leaves a
10 //! truncated session behind. Session ids are sanitized to a filename-safe
11 //! alphabet before touching the filesystem.
12
13 use std::path::PathBuf;
14
15 use serde_json::Value;
16
17 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
18 fn config_dir() -> Option<PathBuf> {
19 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
20 return Some(PathBuf::from(dir));
21 }
22 let home = std::env::var("HOME").ok()?;
23 Some(PathBuf::from(home).join(".config/sigit"))
24 }
25
26 fn sessions_dir() -> Option<PathBuf> {
27 config_dir().map(|dir| dir.join("sessions"))
28 }
29
30 /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through,
31 /// anything else becomes `_`. An empty id maps to `_` so the file name never
32 /// collapses to just the extension.
33 fn sanitize_id(session_id: &str) -> String {
34 if session_id.is_empty() {
35 return "_".to_string();
36 }
37 session_id
38 .chars()
39 .map(|c| {
40 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
41 c
42 } else {
43 '_'
44 }
45 })
46 .collect()
47 }
48
49 fn session_path(session_id: &str) -> Option<PathBuf> {
50 sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
51 }
52
53 /// Persist a history snapshot for `session_id`, replacing any previous save.
54 /// The write is atomic: a temp file in the same directory is renamed over the
55 /// final path.
56 pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
57 let path =
58 session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
59 let dir = path
60 .parent()
61 .ok_or_else(|| "session path has no parent".to_string())?;
62 std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
63
64 let mut body = String::new();
65 for message in history {
66 body.push_str(&message.to_string());
67 body.push('\n');
68 }
69
70 // Unique temp name so two processes saving the same session can't clobber
71 // each other's half-written file; rename is atomic on the same filesystem.
72 let tmp = dir.join(format!(
73 ".{}.{}.tmp",
74 sanitize_id(session_id),
75 std::process::id()
76 ));
77 std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
78 std::fs::rename(&tmp, &path).map_err(|error| {
79 let _ = std::fs::remove_file(&tmp);
80 format!("rename {tmp:?} -> {path:?}: {error}")
81 })?;
82 Ok(())
83 }
84
85 /// Load the saved history for `session_id`, or `None` when no save exists (or
86 /// it cannot be read). Unparseable lines are skipped rather than failing the
87 /// whole restore.
88 pub fn load(session_id: &str) -> Option<Vec<Value>> {
89 let path = session_path(session_id)?;
90 let contents = std::fs::read_to_string(&path).ok()?;
91 Some(
92 contents
93 .lines()
94 .filter(|line| !line.trim().is_empty())
95 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
96 .collect(),
97 )
98 }
99
100 /// Remove the saved history for `session_id`. Missing files are fine.
101 pub fn delete(session_id: &str) {
102 if let Some(path) = session_path(session_id) {
103 let _ = std::fs::remove_file(path);
104 }
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110
111 #[test]
112 fn sanitize_keeps_safe_chars_and_replaces_the_rest() {
113 assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z");
114 assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e");
115 assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
116 assert_eq!(sanitize_id(""), "_");
117 }
118
119 // One test for the filesystem behavior because it mutates the
120 // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings
121 // tests): splitting would race under the parallel test runner.
122 #[test]
123 fn save_load_delete_round_trip() {
124 let _guard = crate::ENV_TEST_LOCK
125 .lock()
126 .unwrap_or_else(|poisoned| poisoned.into_inner());
127 let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
128 let _ = std::fs::remove_dir_all(&dir);
129 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
130 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
131
132 // Missing file → None.
133 assert_eq!(load("nope"), None);
134
135 let history = vec![
136 serde_json::json!({ "role": "system", "content": "sys" }),
137 serde_json::json!({ "role": "user", "content": "hi\nthere" }),
138 serde_json::json!({
139 "role": "assistant", "content": null,
140 "tool_calls": [{ "id": "call_1", "type": "function",
141 "function": { "name": "read_file", "arguments": "{}" } }],
142 }),
143 ];
144 save("sess-1", &history).unwrap();
145 assert_eq!(load("sess-1"), Some(history.clone()));
146
147 // Saving again replaces, not appends.
148 let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })];
149 save("sess-1", &shorter).unwrap();
150 assert_eq!(load("sess-1"), Some(shorter));
151
152 // A hostile id stays inside the sessions dir via sanitization.
153 save("../escape", &history).unwrap();
154 assert!(dir.join("sessions").join(".._escape.jsonl").is_file());
155 assert_eq!(load("../escape"), Some(history));
156 delete("../escape");
157 assert_eq!(load("../escape"), None);
158
159 delete("sess-1");
160 assert_eq!(load("sess-1"), None);
161 // Deleting a missing session is a no-op.
162 delete("sess-1");
163
164 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
165 let _ = std::fs::remove_dir_all(&dir);
166 }
167 }