1use super::event::SessionEvent;
2use super::manager::Session;
3use sha2::{Digest, Sha256};
4use std::{
5 collections::VecDeque,
6 fs,
7 io::{BufRead, BufReader, Read, Seek, SeekFrom},
8 path::PathBuf,
9};
10pub fn validate_session_id(id: String) -> anyhow::Result<String> {
11 if id.is_empty() {
12 anyhow::bail!("session id must not be empty");
13 }
14 if id == "." || id == ".." || id.contains("..") {
15 anyhow::bail!("session id must not contain '..'");
16 }
17 if id.contains('/') || id.contains('\\') {
18 anyhow::bail!("session id must not contain path separators");
19 }
20 if PathBuf::from(&id).is_absolute() {
21 anyhow::bail!("session id must not be an absolute path");
22 }
23 if !id
24 .chars()
25 .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
26 {
27 anyhow::bail!("session id must match [A-Za-z0-9_-]+");
28 }
29 Ok(id)
30}
31
32fn open_session_file(session: &Session) -> anyhow::Result<fs::File> {
33 let root = session
34 .path
35 .parent()
36 .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
37 super::store::open_existing_primary(root, &session.id)?
38 .ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))
39}
40
41#[derive(Debug)]
42pub(crate) enum BoundedReadError {
43 BudgetExceeded(String),
44}
45
46impl std::fmt::Display for BoundedReadError {
47 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::BudgetExceeded(message) => formatter.write_str(message),
50 }
51 }
52}
53
54impl std::error::Error for BoundedReadError {}
55
56fn budget_error(message: impl Into<String>) -> anyhow::Error {
57 anyhow::Error::new(BoundedReadError::BudgetExceeded(message.into()))
58}
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub(crate) struct SessionReadDiagnostic {
61 pub(crate) line: usize,
62 pub(crate) message: String,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub(crate) struct TolerantSessionEvents {
67 pub(crate) events: Vec<SessionEvent>,
68 pub(crate) diagnostics: Vec<SessionReadDiagnostic>,
69 pub(crate) cutoff_bytes: u64,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) struct SessionReadStats {
74 pub lines_read: usize,
75 pub bytes_read: usize,
76 pub content_digest: [u8; 32],
77}
78
79const MAX_TOLERANT_READ_DIAGNOSTICS: usize = 64;
80
81fn push_tolerant_read_diagnostic(
82 diagnostics: &mut Vec<SessionReadDiagnostic>,
83 omitted_count: &mut usize,
84 diagnostic: SessionReadDiagnostic,
85) {
86 if diagnostics.len() < MAX_TOLERANT_READ_DIAGNOSTICS {
87 diagnostics.push(diagnostic);
88 } else {
89 *omitted_count = omitted_count.saturating_add(1);
90 }
91}
92
93fn finalize_tolerant_read_diagnostics(
94 mut diagnostics: Vec<SessionReadDiagnostic>,
95 omitted_count: usize,
96) -> Vec<SessionReadDiagnostic> {
97 if omitted_count == 0 {
98 return diagnostics;
99 }
100 if diagnostics.len() == MAX_TOLERANT_READ_DIAGNOSTICS {
101 diagnostics.pop();
102 }
103 diagnostics.push(SessionReadDiagnostic {
104 line: 0,
105 message: format!(
106 "omitted {omitted_count} additional session JSONL diagnostics after cap of {MAX_TOLERANT_READ_DIAGNOSTICS}"
107 ),
108 });
109 diagnostics
110}
111
112pub(crate) const MAX_METADATA_VISIT_LINES: usize = 100_000;
113pub(crate) const MAX_METADATA_VISIT_BYTES: usize = 64 * 1024 * 1024;
114
115impl Session {
116 #[cfg(unix)]
117 pub(crate) fn open_discovery_file(&self) -> anyhow::Result<fs::File> {
118 open_session_file(self)
119 }
120
121 pub(crate) fn read_events_tolerant_bounded(
122 &self,
123 max_lines: usize,
124 max_bytes: usize,
125 ) -> anyhow::Result<TolerantSessionEvents> {
126 Ok(self
127 .read_events_tolerant_bounded_with_stats(max_lines, max_bytes)?
128 .0)
129 }
130
131 pub(crate) fn read_events_tolerant_bounded_with_stats(
132 &self,
133 max_lines: usize,
134 max_bytes: usize,
135 ) -> anyhow::Result<(TolerantSessionEvents, SessionReadStats)> {
136 let mut events = Vec::new();
137 let (diagnostics, cutoff_bytes, stats) =
138 self.visit_events_tolerant_bounded_with_stats(max_lines, max_bytes, |event| {
139 events.push(event)
140 })?;
141 Ok((
142 TolerantSessionEvents {
143 events,
144 diagnostics,
145 cutoff_bytes: cutoff_bytes as u64,
146 },
147 stats,
148 ))
149 }
150
151 pub(crate) fn history_bytes(&self) -> anyhow::Result<u64> {
152 let root = self
153 .path
154 .parent()
155 .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
156 let Some(file) = super::store::open_existing_primary(root, &self.id)? else {
157 return Ok(0);
158 };
159 Ok(file.metadata()?.len())
160 }
161
162 pub(crate) fn content_fingerprint_bounded(
163 &self,
164 max_bytes: usize,
165 ) -> anyhow::Result<([u8; 32], usize)> {
166 validate_session_id(self.id.clone())?;
167 if !self.path.exists() {
168 return Ok((Sha256::digest([]).into(), 0));
169 }
170 let file = open_session_file(self)?;
171 let mut reader = file.take(
172 u64::try_from(max_bytes)
173 .unwrap_or(u64::MAX)
174 .saturating_add(1),
175 );
176 let mut digest = Sha256::new();
177 let mut total_bytes = 0usize;
178 let mut buffer = [0u8; 8192];
179 loop {
180 let read = reader.read(&mut buffer)?;
181 if read == 0 {
182 break;
183 }
184 total_bytes = total_bytes.saturating_add(read);
185 if total_bytes > max_bytes {
186 return Err(budget_error(format!(
187 "session JSONL tolerant read limit exceeded: {max_bytes} bytes"
188 )));
189 }
190 digest.update(&buffer[..read]);
191 }
192 Ok((digest.finalize().into(), total_bytes))
193 }
194
195 pub(crate) fn visit_events_tolerant_bounded(
196 &self,
197 max_lines: usize,
198 max_bytes: usize,
199 visit: impl FnMut(SessionEvent),
200 ) -> anyhow::Result<(Vec<SessionReadDiagnostic>, usize)> {
201 let (diagnostics, bytes, _) =
202 self.visit_events_tolerant_bounded_with_stats(max_lines, max_bytes, visit)?;
203 Ok((diagnostics, bytes))
204 }
205
206 fn visit_events_tolerant_bounded_with_stats(
207 &self,
208 max_lines: usize,
209 max_bytes: usize,
210 mut visit: impl FnMut(SessionEvent),
211 ) -> anyhow::Result<(Vec<SessionReadDiagnostic>, usize, SessionReadStats)> {
212 validate_session_id(self.id.clone())?;
213 if !self.path.exists() {
214 return Ok((
215 Vec::new(),
216 0,
217 SessionReadStats {
218 lines_read: 0,
219 bytes_read: 0,
220 content_digest: Sha256::digest([]).into(),
221 },
222 ));
223 }
224 let file = open_session_file(self)?;
225 let mut reader = BufReader::new(file);
226 let mut diagnostics = Vec::new();
227 let mut omitted_diagnostics = 0usize;
228 let mut total_bytes = 0usize;
229 let mut lines_read = 0usize;
230 let mut digest = Sha256::new();
231 let mut line = Vec::new();
232 for line_number in 1..=max_lines {
233 line.clear();
234 let remaining = max_bytes.saturating_sub(total_bytes);
235 if remaining == 0 {
236 if !reader.fill_buf()?.is_empty() {
237 return Err(budget_error(format!(
238 "session JSONL tolerant read limit exceeded: {max_bytes} bytes"
239 )));
240 }
241 break;
242 }
243 let read = (&mut reader)
244 .take(
245 u64::try_from(remaining)
246 .unwrap_or(u64::MAX)
247 .saturating_add(1),
248 )
249 .read_until(b'\n', &mut line)?;
250 if read == 0 {
251 break;
252 }
253 if read > remaining {
254 return Err(budget_error(format!(
255 "session JSONL tolerant read limit exceeded at line {line_number}: {max_bytes} bytes"
256 )));
257 }
258 total_bytes = total_bytes.saturating_add(read);
259 lines_read = lines_read.saturating_add(1);
260 digest.update(&line);
261 if line.last() == Some(&b'\n') {
262 line.pop();
263 if line.last() == Some(&b'\r') {
264 line.pop();
265 }
266 }
267 match serde_json::from_slice::<SessionEvent>(&line) {
268 Ok(event) => visit(event),
269 Err(_) => push_tolerant_read_diagnostic(
270 &mut diagnostics,
271 &mut omitted_diagnostics,
272 SessionReadDiagnostic {
273 line: line_number,
274 message: format!(
275 "operation=replay category=session_jsonl failed to parse session JSONL at line {line_number}"
276 ),
277 },
278 ),
279 }
280 }
281 if !reader.fill_buf()?.is_empty() {
282 return Err(budget_error(format!(
283 "session JSONL tolerant read limit exceeded: more than {max_lines} lines or {max_bytes} bytes"
284 )));
285 }
286 let diagnostics = finalize_tolerant_read_diagnostics(diagnostics, omitted_diagnostics);
287 Ok((
288 diagnostics,
289 total_bytes,
290 SessionReadStats {
291 lines_read,
292 bytes_read: total_bytes,
293 content_digest: digest.finalize().into(),
294 },
295 ))
296 }
297
298 pub(crate) fn read_recent_events_tolerant(
299 &self,
300 max_events: usize,
301 max_bytes: usize,
302 ) -> anyhow::Result<TolerantSessionEvents> {
303 validate_session_id(self.id.clone())?;
304 if !self.path.exists() || max_events == 0 || max_bytes == 0 {
305 return Ok(TolerantSessionEvents {
306 events: Vec::new(),
307 diagnostics: Vec::new(),
308 cutoff_bytes: 0,
309 });
310 }
311 let file = open_session_file(self)?;
312 let lines = BufReader::new(file)
313 .split(b'\n')
314 .enumerate()
315 .map(|(index, line)| (index + 1, line));
316 self.collect_recent_events_tolerant_lines(lines, max_events, max_bytes)
317 }
318
319 pub(crate) fn read_recent_events_tolerant_tail(
320 &self,
321 max_events: usize,
322 max_retained_bytes: usize,
323 max_read_bytes: usize,
324 ) -> anyhow::Result<TolerantSessionEvents> {
325 validate_session_id(self.id.clone())?;
326 if max_events == 0 || max_retained_bytes == 0 || max_read_bytes == 0 {
327 anyhow::bail!(
328 "session JSONL tolerant tail read limits must be non-zero: max_events={max_events}, max_retained_bytes={max_retained_bytes}, max_read_bytes={max_read_bytes}"
329 );
330 }
331 if !self.path.exists() {
332 return Ok(TolerantSessionEvents {
333 events: Vec::new(),
334 diagnostics: Vec::new(),
335 cutoff_bytes: 0,
336 });
337 }
338 let mut file = open_session_file(self)?;
341 let file_len = file.metadata()?.len();
342 let read_bytes = file_len.min(u64::try_from(max_read_bytes).unwrap_or(u64::MAX));
343 let start = file_len - read_bytes;
344 file.seek(SeekFrom::Start(start))?;
345 let mut tail = Vec::with_capacity(read_bytes as usize);
346 file.take(read_bytes).read_to_end(&mut tail)?;
347
348 let first_complete_line = if start == 0 {
349 0
350 } else {
351 tail.iter()
352 .position(|byte| *byte == b'\n')
353 .map_or(tail.len(), |index| index + 1)
354 };
355 let tail = &tail[first_complete_line..];
356 let mut line_count = 0;
357 let lines = tail
358 .split_inclusive(|byte| *byte == b'\n')
359 .enumerate()
360 .inspect(|_| line_count += 1)
361 .map(|(index, line)| {
362 let line = line.strip_suffix(b"\n").unwrap_or(line);
363 (index + 1, Ok(line.to_vec()))
364 });
365 let mut result =
366 self.collect_recent_events_tolerant_lines(lines, max_events, max_retained_bytes)?;
367 if start > 0 || (line_count > result.events.len() && result.diagnostics.is_empty()) {
368 result.diagnostics.insert(
369 0,
370 SessionReadDiagnostic {
371 line: 0,
372 message: format!(
373 "operation=recent_context category=session_jsonl bounded tail window; omitted older lines; read final {max_read_bytes} of {file_len} bytes"
374 ),
375 },
376 );
377 }
378 Ok(result)
379 }
380
381 fn collect_recent_events_tolerant_lines(
382 &self,
383 lines: impl IntoIterator<Item = (usize, Result<Vec<u8>, std::io::Error>)>,
384 max_events: usize,
385 max_bytes: usize,
386 ) -> anyhow::Result<TolerantSessionEvents> {
387 let mut retained = VecDeque::new();
388 let mut retained_bytes = 0usize;
389 let mut malformed_bytes = 0usize;
390 let mut diagnostics = Vec::new();
391 let mut omitted_diagnostics = 0usize;
392 for (line_number, line) in lines {
393 match line {
394 Ok(line) => {
395 let line_bytes = line.len() + 1;
396 match serde_json::from_slice::<SessionEvent>(&line) {
397 Ok(event) => {
398 retained_bytes = retained_bytes.saturating_add(line_bytes);
399 retained.push_back((event, line_bytes));
400 while retained.len() > max_events || retained_bytes > max_bytes {
401 if let Some((_, bytes)) = retained.pop_front() {
402 retained_bytes = retained_bytes.saturating_sub(bytes);
403 } else {
404 break;
405 }
406 }
407 }
408 Err(_) => {
409 malformed_bytes = malformed_bytes.saturating_add(line_bytes);
410 if malformed_bytes > max_bytes {
411 anyhow::bail!(
412 "operation=recent_context category=session_jsonl tolerant recent read limit exceeded: malformed bytes > {max_bytes}"
413 );
414 }
415 push_tolerant_read_diagnostic(
416 &mut diagnostics,
417 &mut omitted_diagnostics,
418 SessionReadDiagnostic {
419 line: line_number,
420 message: format!(
421 "operation=recent_context category=session_jsonl failed to parse session JSONL at line {line_number}"
422 ),
423 },
424 );
425 }
426 }
427 }
428 Err(_) => push_tolerant_read_diagnostic(
429 &mut diagnostics,
430 &mut omitted_diagnostics,
431 SessionReadDiagnostic {
432 line: line_number,
433 message: format!(
434 "operation=recent_context category=session_jsonl read failure at line {line_number}"
435 ),
436 },
437 ),
438 }
439 }
440 Ok(TolerantSessionEvents {
441 events: retained.into_iter().map(|(event, _)| event).collect(),
442 diagnostics: finalize_tolerant_read_diagnostics(diagnostics, omitted_diagnostics),
443 cutoff_bytes: 0,
444 })
445 }
446}