magi_code/sessions/
manager.rs1use super::metadata::SessionMetadataSummary;
2use super::read::validate_session_id;
3use super::store::{prepare_session_root, primary_path};
4use std::path::{Path, PathBuf};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub(crate) struct SessionInternalDiagnostic {
9 pub(crate) session_id: Option<String>,
10 pub(crate) message: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub(crate) struct SessionListReport {
15 pub(crate) summaries: Vec<SessionMetadataSummary>,
16 pub(crate) diagnostics: Vec<SessionInternalDiagnostic>,
17}
18
19#[derive(Debug, Clone)]
20pub struct SessionManager {
21 pub(in crate::sessions) root: PathBuf,
22}
23
24impl SessionManager {
25 #[must_use]
26 pub fn new(root: PathBuf) -> Self {
27 Self { root }
28 }
29
30 pub fn create(&self) -> anyhow::Result<Session> {
31 prepare_session_root(&self.root)?;
32 let id = Uuid::new_v4().to_string();
33 let path = self.path_for_valid_id(&id)?;
34 Ok(Session { id, path })
36 }
37
38 pub fn open(&self, id: impl Into<String>) -> anyhow::Result<Session> {
39 let id = validate_session_id(id.into())?;
40 Ok(Session {
41 path: self.path_for_valid_id(&id)?,
42 id,
43 })
44 }
45
46 pub(crate) fn open_existing(&self, id: impl Into<String>) -> anyhow::Result<Session> {
47 let session = self.open(id)?;
48 super::store::open_existing_primary(&self.root, &session.id)?
49 .ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))?;
50 Ok(session)
51 }
52
53 pub fn list(&self) -> anyhow::Result<Vec<Session>> {
54 Ok(self
55 .list_metadata_summaries()?
56 .into_iter()
57 .map(|summary| summary.session)
58 .collect())
59 }
60
61 pub fn most_recent(&self) -> anyhow::Result<Option<Session>> {
62 let report = self.list_metadata_report()?;
63 if !report.diagnostics.is_empty() {
64 anyhow::bail!("session discovery found unreadable or unsafe history");
65 }
66 Ok(report
67 .summaries
68 .into_iter()
69 .last()
70 .map(|summary| summary.session))
71 }
72
73 pub(crate) fn path_for_valid_id(&self, id: &str) -> anyhow::Result<PathBuf> {
74 primary_path(&self.root, id)
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Session {
80 pub(in crate::sessions) id: String,
81 pub(in crate::sessions) path: PathBuf,
82}
83
84impl Session {
85 pub fn id(&self) -> &str {
86 &self.id
87 }
88
89 pub fn path(&self) -> &Path {
90 &self.path
91 }
92
93 #[cfg(test)]
94 pub(crate) fn unchecked_for_test(id: String, path: PathBuf) -> Self {
95 Self { id, path }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use proptest::prelude::*;
103 use std::path::Component;
104 use tempfile::TempDir;
105
106 fn valid_session_id_strategy() -> impl Strategy<Value = String> {
107 proptest::string::string_regex("[A-Za-z0-9_-]{1,64}").unwrap()
108 }
109
110 fn invalid_session_id_strategy() -> impl Strategy<Value = String> {
111 prop_oneof![
112 Just(String::new()),
113 Just(".".to_string()),
114 any::<String>().prop_map(|value| format!("{value}..")),
115 any::<String>().prop_map(|value| format!("{value}/{value}")),
116 any::<String>().prop_map(|value| format!("{value}\\{value}")),
117 any::<String>().prop_map(|value| format!("{value}.jsonl")),
118 any::<String>().prop_map(|value| format!("{value}é")),
119 ]
120 }
121
122 fn lexical_normalize(path: &Path) -> PathBuf {
123 let mut normalized = PathBuf::new();
124 for component in path.components() {
125 match component {
126 Component::CurDir => {}
127 Component::ParentDir => {
128 normalized.pop();
129 }
130 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
131 normalized.push(component.as_os_str());
132 }
133 }
134 }
135 normalized
136 }
137
138 proptest! {
139 #[test]
140 fn path_for_valid_id_keeps_valid_ids_under_session_root(id in valid_session_id_strategy()) {
141 let temp = TempDir::new().unwrap();
142 let root = temp.path().join("sessions");
143 let manager = SessionManager::new(root.clone());
144 let path = manager.path_for_valid_id(&id).unwrap();
145 let normalized_root = lexical_normalize(&root);
146 let normalized_path = lexical_normalize(&path);
147 let expected_file_name = format!("{id}.jsonl");
148
149 prop_assert!(normalized_path.starts_with(&normalized_root));
150 prop_assert_eq!(normalized_path.parent(), Some(normalized_root.as_path()));
151 prop_assert_eq!(
152 normalized_path.file_name().and_then(|file_name| file_name.to_str()),
153 Some(expected_file_name.as_str())
154 );
155
156 let session = manager.open(id.clone()).unwrap();
157 prop_assert_eq!(session.id(), id);
158 prop_assert_eq!(session.path(), path.as_path());
159 }
160
161 #[test]
162 fn open_and_path_for_valid_id_reject_generated_unsafe_ids(id in invalid_session_id_strategy()) {
163 let temp = TempDir::new().unwrap();
164 let manager = SessionManager::new(temp.path().join("sessions"));
165
166 prop_assert!(manager.open(id.clone()).is_err());
167 prop_assert!(manager.path_for_valid_id(&id).is_err());
168 }
169 }
170
171 #[test]
172 fn session_open_rejects_unsafe_ids_before_joining_paths() {
173 let temp = TempDir::new().unwrap();
174 let manager = SessionManager::new(temp.path().join("sessions"));
175 for id in [
176 "",
177 "..",
178 "../escape",
179 "nested/id",
180 "nested\\id",
181 "/absolute",
182 "bad.jsonl",
183 ] {
184 assert!(manager.open(id).is_err(), "accepted unsafe id {id:?}");
185 }
186 assert!(manager.open("safe_ID-123").is_ok());
187 }
188}