1use super::event::SessionEvent;
2use super::manager::Session;
3use sha2::{Digest, Sha256};
4#[cfg(test)]
5use std::time::SystemTime;
6use std::{
7 collections::VecDeque,
8 fs,
9 io::{BufRead, BufReader, Read, Seek, SeekFrom},
10 path::PathBuf,
11};
12pub fn validate_session_id(id: String) -> anyhow::Result<String> {
13 if id.is_empty() {
14 anyhow::bail!("session id must not be empty");
15 }
16 if id == "." || id == ".." || id.contains("..") {
17 anyhow::bail!("session id must not contain '..'");
18 }
19 if id.contains('/') || id.contains('\\') {
20 anyhow::bail!("session id must not contain path separators");
21 }
22 if PathBuf::from(&id).is_absolute() {
23 anyhow::bail!("session id must not be an absolute path");
24 }
25 if !id
26 .chars()
27 .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
28 {
29 anyhow::bail!("session id must match [A-Za-z0-9_-]+");
30 }
31 Ok(id)
32}
33
34fn open_session_file(session: &Session) -> anyhow::Result<fs::File> {
35 let root = session
36 .path
37 .parent()
38 .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
39 super::store::open_existing_primary(root, &session.id)?
40 .ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))
41}
42#[cfg(test)]
43pub(crate) fn latest_valid_event_timestamp_streaming(session: &Session) -> Option<SystemTime> {
44 let root = session.path.parent()?;
45 let file = super::store::open_existing_primary(root, &session.id).ok()??;
46 let mut latest = None;
47 for line in BufReader::new(file).lines() {
48 let Ok(line) = line else {
49 continue;
50 };
51 if line.trim().is_empty() {
52 continue;
53 }
54 let Ok(event) = serde_json::from_str::<SessionEvent>(&line) else {
55 continue;
56 };
57 let timestamp = SystemTime::from(event.timestamp);
58 latest = Some(latest.map_or(timestamp, |current: SystemTime| current.max(timestamp)));
59 }
60 latest
61}
62
63#[derive(Debug)]
64pub(crate) enum BoundedReadError {
65 BudgetExceeded(String),
66}
67
68impl std::fmt::Display for BoundedReadError {
69 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::BudgetExceeded(message) => formatter.write_str(message),
72 }
73 }
74}
75
76impl std::error::Error for BoundedReadError {}
77
78fn budget_error(message: impl Into<String>) -> anyhow::Error {
79 anyhow::Error::new(BoundedReadError::BudgetExceeded(message.into()))
80}
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub(crate) struct SessionReadDiagnostic {
83 pub(crate) line: usize,
84 pub(crate) message: String,
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub(crate) struct TolerantSessionEvents {
89 pub(crate) events: Vec<SessionEvent>,
90 pub(crate) diagnostics: Vec<SessionReadDiagnostic>,
91 pub(crate) cutoff_bytes: u64,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub(crate) struct SessionReadStats {
96 pub lines_read: usize,
97 pub bytes_read: usize,
98 pub content_digest: [u8; 32],
99}
100
101const MAX_TOLERANT_READ_DIAGNOSTICS: usize = 64;
102
103fn push_tolerant_read_diagnostic(
104 diagnostics: &mut Vec<SessionReadDiagnostic>,
105 omitted_count: &mut usize,
106 diagnostic: SessionReadDiagnostic,
107) {
108 if diagnostics.len() < MAX_TOLERANT_READ_DIAGNOSTICS {
109 diagnostics.push(diagnostic);
110 } else {
111 *omitted_count = omitted_count.saturating_add(1);
112 }
113}
114
115fn finalize_tolerant_read_diagnostics(
116 mut diagnostics: Vec<SessionReadDiagnostic>,
117 omitted_count: usize,
118) -> Vec<SessionReadDiagnostic> {
119 if omitted_count == 0 {
120 return diagnostics;
121 }
122 if diagnostics.len() == MAX_TOLERANT_READ_DIAGNOSTICS {
123 diagnostics.pop();
124 }
125 diagnostics.push(SessionReadDiagnostic {
126 line: 0,
127 message: format!(
128 "omitted {omitted_count} additional session JSONL diagnostics after cap of {MAX_TOLERANT_READ_DIAGNOSTICS}"
129 ),
130 });
131 diagnostics
132}
133
134pub(crate) const MAX_METADATA_VISIT_LINES: usize = 100_000;
135pub(crate) const MAX_METADATA_VISIT_BYTES: usize = 64 * 1024 * 1024;
136
137impl Session {
138 #[cfg(test)]
139 pub(crate) fn read_events(&self) -> anyhow::Result<Vec<SessionEvent>> {
140 validate_session_id(self.id.clone())?;
141 if !self.path.exists() {
142 return Ok(Vec::new());
143 }
144 self.read_event_lines_streaming()?
145 .map(|event_line| event_line.map(|(event, _)| event))
146 .collect()
147 }
148
149 #[cfg(test)]
150 pub(crate) fn read_recent_events(
151 &self,
152 max_events: usize,
153 max_bytes: usize,
154 ) -> anyhow::Result<Vec<SessionEvent>> {
155 validate_session_id(self.id.clone())?;
156 if !self.path.exists() || max_events == 0 || max_bytes == 0 {
157 return Ok(Vec::new());
158 }
159 let mut retained = VecDeque::new();
160 let mut retained_bytes = 0usize;
161 for event_line in self.read_event_lines_streaming()? {
162 let (event, line_bytes) = event_line?;
163 retained_bytes = retained_bytes.saturating_add(line_bytes);
165 retained.push_back((event, line_bytes));
166 while retained.len() > max_events || retained_bytes > max_bytes {
167 if let Some((_, bytes)) = retained.pop_front() {
168 retained_bytes = retained_bytes.saturating_sub(bytes);
169 } else {
170 break;
171 }
172 }
173 }
174 Ok(retained.into_iter().map(|(event, _)| event).collect())
175 }
176
177 #[cfg(test)]
178 pub(crate) fn read_events_tolerant(&self) -> anyhow::Result<TolerantSessionEvents> {
179 self.read_events_tolerant_bounded(usize::MAX, usize::MAX)
180 }
181
182 pub(crate) fn read_events_tolerant_bounded(
183 &self,
184 max_lines: usize,
185 max_bytes: usize,
186 ) -> anyhow::Result<TolerantSessionEvents> {
187 Ok(self
188 .read_events_tolerant_bounded_with_stats(max_lines, max_bytes)?
189 .0)
190 }
191
192 pub(crate) fn read_events_tolerant_bounded_with_stats(
193 &self,
194 max_lines: usize,
195 max_bytes: usize,
196 ) -> anyhow::Result<(TolerantSessionEvents, SessionReadStats)> {
197 let mut events = Vec::new();
198 let (diagnostics, cutoff_bytes, stats) =
199 self.visit_events_tolerant_bounded_with_stats(max_lines, max_bytes, |event| {
200 events.push(event)
201 })?;
202 Ok((
203 TolerantSessionEvents {
204 events,
205 diagnostics,
206 cutoff_bytes: cutoff_bytes as u64,
207 },
208 stats,
209 ))
210 }
211
212 pub(crate) fn content_fingerprint_bounded(
213 &self,
214 max_bytes: usize,
215 ) -> anyhow::Result<([u8; 32], usize)> {
216 validate_session_id(self.id.clone())?;
217 if !self.path.exists() {
218 return Ok((Sha256::digest([]).into(), 0));
219 }
220 let file = open_session_file(self)?;
221 let mut reader = file.take(
222 u64::try_from(max_bytes)
223 .unwrap_or(u64::MAX)
224 .saturating_add(1),
225 );
226 let mut digest = Sha256::new();
227 let mut total_bytes = 0usize;
228 let mut buffer = [0u8; 8192];
229 loop {
230 let read = reader.read(&mut buffer)?;
231 if read == 0 {
232 break;
233 }
234 total_bytes = total_bytes.saturating_add(read);
235 if total_bytes > max_bytes {
236 return Err(budget_error(format!(
237 "session JSONL tolerant read limit exceeded: {max_bytes} bytes"
238 )));
239 }
240 digest.update(&buffer[..read]);
241 }
242 Ok((digest.finalize().into(), total_bytes))
243 }
244
245 pub(crate) fn visit_events_tolerant_bounded(
246 &self,
247 max_lines: usize,
248 max_bytes: usize,
249 visit: impl FnMut(SessionEvent),
250 ) -> anyhow::Result<(Vec<SessionReadDiagnostic>, usize)> {
251 let (diagnostics, bytes, _) =
252 self.visit_events_tolerant_bounded_with_stats(max_lines, max_bytes, visit)?;
253 Ok((diagnostics, bytes))
254 }
255
256 fn visit_events_tolerant_bounded_with_stats(
257 &self,
258 max_lines: usize,
259 max_bytes: usize,
260 mut visit: impl FnMut(SessionEvent),
261 ) -> anyhow::Result<(Vec<SessionReadDiagnostic>, usize, SessionReadStats)> {
262 validate_session_id(self.id.clone())?;
263 if !self.path.exists() {
264 return Ok((
265 Vec::new(),
266 0,
267 SessionReadStats {
268 lines_read: 0,
269 bytes_read: 0,
270 content_digest: Sha256::digest([]).into(),
271 },
272 ));
273 }
274 let file = open_session_file(self)?;
275 let mut reader = BufReader::new(file);
276 let mut diagnostics = Vec::new();
277 let mut omitted_diagnostics = 0usize;
278 let mut total_bytes = 0usize;
279 let mut lines_read = 0usize;
280 let mut digest = Sha256::new();
281 let mut line = Vec::new();
282 for line_number in 1..=max_lines {
283 line.clear();
284 let remaining = max_bytes.saturating_sub(total_bytes);
285 if remaining == 0 {
286 if !reader.fill_buf()?.is_empty() {
287 return Err(budget_error(format!(
288 "session JSONL tolerant read limit exceeded: {max_bytes} bytes"
289 )));
290 }
291 break;
292 }
293 let read = (&mut reader)
294 .take(
295 u64::try_from(remaining)
296 .unwrap_or(u64::MAX)
297 .saturating_add(1),
298 )
299 .read_until(b'\n', &mut line)?;
300 if read == 0 {
301 break;
302 }
303 if read > remaining {
304 return Err(budget_error(format!(
305 "session JSONL tolerant read limit exceeded at line {line_number}: {max_bytes} bytes"
306 )));
307 }
308 total_bytes = total_bytes.saturating_add(read);
309 lines_read = lines_read.saturating_add(1);
310 digest.update(&line);
311 if line.last() == Some(&b'\n') {
312 line.pop();
313 if line.last() == Some(&b'\r') {
314 line.pop();
315 }
316 }
317 match serde_json::from_slice::<SessionEvent>(&line) {
318 Ok(event) => visit(event),
319 Err(_) => push_tolerant_read_diagnostic(
320 &mut diagnostics,
321 &mut omitted_diagnostics,
322 SessionReadDiagnostic {
323 line: line_number,
324 message: format!(
325 "operation=replay category=session_jsonl failed to parse session JSONL at line {line_number}"
326 ),
327 },
328 ),
329 }
330 }
331 if !reader.fill_buf()?.is_empty() {
332 return Err(budget_error(format!(
333 "session JSONL tolerant read limit exceeded: more than {max_lines} lines or {max_bytes} bytes"
334 )));
335 }
336 let diagnostics = finalize_tolerant_read_diagnostics(diagnostics, omitted_diagnostics);
337 Ok((
338 diagnostics,
339 total_bytes,
340 SessionReadStats {
341 lines_read,
342 bytes_read: total_bytes,
343 content_digest: digest.finalize().into(),
344 },
345 ))
346 }
347
348 pub(crate) fn read_recent_events_tolerant(
349 &self,
350 max_events: usize,
351 max_bytes: usize,
352 ) -> anyhow::Result<TolerantSessionEvents> {
353 validate_session_id(self.id.clone())?;
354 if !self.path.exists() || max_events == 0 || max_bytes == 0 {
355 return Ok(TolerantSessionEvents {
356 events: Vec::new(),
357 diagnostics: Vec::new(),
358 cutoff_bytes: 0,
359 });
360 }
361 let file = open_session_file(self)?;
362 let lines = BufReader::new(file)
363 .split(b'\n')
364 .enumerate()
365 .map(|(index, line)| (index + 1, line));
366 self.collect_recent_events_tolerant_lines(lines, max_events, max_bytes)
367 }
368
369 pub(crate) fn read_recent_events_tolerant_tail(
370 &self,
371 max_events: usize,
372 max_retained_bytes: usize,
373 max_read_bytes: usize,
374 ) -> anyhow::Result<TolerantSessionEvents> {
375 validate_session_id(self.id.clone())?;
376 if max_events == 0 || max_retained_bytes == 0 || max_read_bytes == 0 {
377 anyhow::bail!(
378 "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}"
379 );
380 }
381 if !self.path.exists() {
382 return Ok(TolerantSessionEvents {
383 events: Vec::new(),
384 diagnostics: Vec::new(),
385 cutoff_bytes: 0,
386 });
387 }
388 let mut file = open_session_file(self)?;
391 let file_len = file.metadata()?.len();
392 let read_bytes = file_len.min(u64::try_from(max_read_bytes).unwrap_or(u64::MAX));
393 let start = file_len - read_bytes;
394 file.seek(SeekFrom::Start(start))?;
395 let mut tail = Vec::with_capacity(read_bytes as usize);
396 file.take(read_bytes).read_to_end(&mut tail)?;
397
398 let first_complete_line = if start == 0 {
399 0
400 } else {
401 tail.iter()
402 .position(|byte| *byte == b'\n')
403 .map_or(tail.len(), |index| index + 1)
404 };
405 let tail = &tail[first_complete_line..];
406 let mut line_count = 0;
407 let lines = tail
408 .split_inclusive(|byte| *byte == b'\n')
409 .enumerate()
410 .inspect(|_| line_count += 1)
411 .map(|(index, line)| {
412 let line = line.strip_suffix(b"\n").unwrap_or(line);
413 (index + 1, Ok(line.to_vec()))
414 });
415 let mut result =
416 self.collect_recent_events_tolerant_lines(lines, max_events, max_retained_bytes)?;
417 if start > 0 || (line_count > result.events.len() && result.diagnostics.is_empty()) {
418 result.diagnostics.insert(
419 0,
420 SessionReadDiagnostic {
421 line: 0,
422 message: format!(
423 "operation=recent_context category=session_jsonl bounded tail window; omitted older lines; read final {max_read_bytes} of {file_len} bytes"
424 ),
425 },
426 );
427 }
428 Ok(result)
429 }
430
431 fn collect_recent_events_tolerant_lines(
432 &self,
433 lines: impl IntoIterator<Item = (usize, Result<Vec<u8>, std::io::Error>)>,
434 max_events: usize,
435 max_bytes: usize,
436 ) -> anyhow::Result<TolerantSessionEvents> {
437 let mut retained = VecDeque::new();
438 let mut retained_bytes = 0usize;
439 let mut malformed_bytes = 0usize;
440 let mut diagnostics = Vec::new();
441 let mut omitted_diagnostics = 0usize;
442 for (line_number, line) in lines {
443 match line {
444 Ok(line) => {
445 let line_bytes = line.len() + 1;
446 match serde_json::from_slice::<SessionEvent>(&line) {
447 Ok(event) => {
448 retained_bytes = retained_bytes.saturating_add(line_bytes);
449 retained.push_back((event, line_bytes));
450 while retained.len() > max_events || retained_bytes > max_bytes {
451 if let Some((_, bytes)) = retained.pop_front() {
452 retained_bytes = retained_bytes.saturating_sub(bytes);
453 } else {
454 break;
455 }
456 }
457 }
458 Err(_) => {
459 malformed_bytes = malformed_bytes.saturating_add(line_bytes);
460 if malformed_bytes > max_bytes {
461 anyhow::bail!(
462 "operation=recent_context category=session_jsonl tolerant recent read limit exceeded: malformed bytes > {max_bytes}"
463 );
464 }
465 push_tolerant_read_diagnostic(
466 &mut diagnostics,
467 &mut omitted_diagnostics,
468 SessionReadDiagnostic {
469 line: line_number,
470 message: format!(
471 "operation=recent_context category=session_jsonl failed to parse session JSONL at line {line_number}"
472 ),
473 },
474 );
475 }
476 }
477 }
478 Err(_) => push_tolerant_read_diagnostic(
479 &mut diagnostics,
480 &mut omitted_diagnostics,
481 SessionReadDiagnostic {
482 line: line_number,
483 message: format!(
484 "operation=recent_context category=session_jsonl read failure at line {line_number}"
485 ),
486 },
487 ),
488 }
489 }
490 Ok(TolerantSessionEvents {
491 events: retained.into_iter().map(|(event, _)| event).collect(),
492 diagnostics: finalize_tolerant_read_diagnostics(diagnostics, omitted_diagnostics),
493 cutoff_bytes: 0,
494 })
495 }
496
497 #[cfg(test)]
498 pub(crate) fn latest_event_timestamp_bounded(&self) -> anyhow::Result<Option<SystemTime>> {
499 validate_session_id(self.id.clone())?;
500 if !self.path.exists() {
501 return Ok(None);
502 }
503 let mut latest = None;
504 for event_line in self.read_event_lines_streaming()? {
505 let timestamp = SystemTime::from(event_line?.0.timestamp);
506 latest = Some(latest.map_or(timestamp, |current: SystemTime| current.max(timestamp)));
507 }
508 Ok(latest)
509 }
510
511 #[cfg(test)]
512 fn read_event_lines_streaming(
513 &self,
514 ) -> anyhow::Result<impl Iterator<Item = anyhow::Result<(SessionEvent, usize)>> + '_> {
515 let file = open_session_file(self)?;
516 Ok(BufReader::new(file)
517 .lines()
518 .enumerate()
519 .map(|(index, line)| {
520 let line = line.map_err(|error| {
521 anyhow::anyhow!(
522 "failed to read session JSONL at {} line {}: {error}",
523 self.path.display(),
524 index + 1
525 )
526 })?;
527 let line_bytes = line.len() + 1;
529 let event = serde_json::from_str::<SessionEvent>(&line).map_err(|error| {
530 anyhow::anyhow!(
531 "failed to parse session JSONL at {} line {}: {error}",
532 self.path.display(),
533 index + 1
534 )
535 })?;
536 Ok((event, line_bytes))
537 }))
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::super::manager::SessionManager;
544 use super::*;
545 use proptest::prelude::*;
546 use serde_json::json;
547 use tempfile::TempDir;
548
549 fn valid_session_id_strategy() -> impl Strategy<Value = String> {
550 proptest::string::string_regex("[A-Za-z0-9_-]{1,64}").unwrap()
551 }
552
553 fn invalid_session_id_strategy() -> impl Strategy<Value = String> {
554 prop_oneof![
555 Just(String::new()),
556 Just(".".to_string()),
557 any::<String>().prop_map(|value| format!("{value}..")),
558 any::<String>().prop_map(|value| format!("{value}/{value}")),
559 any::<String>().prop_map(|value| format!("{value}\\{value}")),
560 any::<String>().prop_map(|value| format!("{value}.jsonl")),
561 any::<String>().prop_map(|value| format!("{value}é")),
562 ]
563 }
564
565 fn invalid_utf8_event_line(event: &SessionEvent, marker: &[u8]) -> Vec<u8> {
566 let mut line = serde_json::to_vec(event).unwrap();
567 let marker_start = line
568 .windows(marker.len())
569 .position(|window| window == marker)
570 .expect("test marker must be present in serialized event");
571 line[marker_start] = 0xff;
572 line
573 }
574
575 proptest! {
576 #[test]
577 fn validate_session_id_accepts_only_non_empty_safe_ascii_ids(id in valid_session_id_strategy()) {
578 let validated = validate_session_id(id.clone()).unwrap();
579
580 prop_assert_eq!(&validated, &id);
581 prop_assert!(!validated.is_empty());
582 prop_assert!(validated
583 .chars()
584 .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-'));
585 }
586
587 #[test]
588 fn validate_session_id_rejects_generated_unsafe_ids(id in invalid_session_id_strategy()) {
589 prop_assert!(validate_session_id(id).is_err());
590 }
591 }
592
593 #[test]
594 fn recent_and_latest_session_reads_are_bounded_streaming_paths() {
595 let temp = TempDir::new().unwrap();
596 let manager = SessionManager::new(temp.path().join("sessions"));
597 let session = manager.create().unwrap();
598 for index in 0..25 {
599 session
600 .append(&SessionEvent::new(
601 "event",
602 session.id().to_string(),
603 temp.path().to_path_buf(),
604 json!({"index": index}),
605 ))
606 .unwrap();
607 }
608 let recent = session.read_recent_events(3, 4096).unwrap();
609 assert_eq!(recent.len(), 3);
610 assert_eq!(recent[0].payload["index"], 22);
611 assert!(session.latest_event_timestamp_bounded().unwrap().is_some());
612 assert_eq!(manager.most_recent().unwrap().unwrap().id(), session.id());
613 }
614
615 #[test]
616 fn read_recent_events_uses_original_jsonl_line_bytes() {
617 let temp = TempDir::new().unwrap();
618 let manager = SessionManager::new(temp.path().join("sessions"));
619 let session = manager.create().unwrap();
620 let padded = SessionEvent::new(
621 "event",
622 session.id().to_string(),
623 temp.path().to_path_buf(),
624 json!({"index": 1}),
625 );
626 let recent = SessionEvent::new(
627 "event",
628 session.id().to_string(),
629 temp.path().to_path_buf(),
630 json!({"index": 2}),
631 );
632 let padded_compact_len = serde_json::to_vec(&padded).unwrap().len();
633 let mut padded_line = serde_json::to_value(&padded).unwrap();
634 padded_line["padding"] = json!("x".repeat(2048));
635 let padded_line = serde_json::to_string(&padded_line).unwrap();
636 let recent_line = serde_json::to_string(&recent).unwrap();
637 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
638 fs::write(session.path(), format!("{padded_line}\n{recent_line}\n")).unwrap();
639 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
640
641 let max_bytes = padded_compact_len + recent_line.len() + 2;
642 let events = session.read_recent_events(10, max_bytes).unwrap();
643
644 assert_eq!(events.len(), 1);
645 assert_eq!(events[0].payload["index"], 2);
646 }
647
648 #[test]
649 fn tolerant_read_reports_malformed_lines_without_payload_leak() {
650 let temp = TempDir::new().unwrap();
651 let manager = SessionManager::new(temp.path().join("sessions"));
652 let session = manager.open("safe").unwrap();
653 let first = SessionEvent::new(
654 "user_input",
655 session.id().to_string(),
656 temp.path().to_path_buf(),
657 json!({"text":"before"}),
658 );
659 let second = SessionEvent::new(
660 "assistant_output",
661 session.id().to_string(),
662 temp.path().to_path_buf(),
663 json!({"text":"after"}),
664 );
665 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
666 fs::write(
667 session.path(),
668 format!(
669 "{}\n{{\"access_token\":\"secret-token\",\n{}\n",
670 serde_json::to_string(&first).unwrap(),
671 serde_json::to_string(&second).unwrap()
672 ),
673 )
674 .unwrap();
675 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
676
677 assert!(session.read_events().is_err());
678 let tolerant = session.read_events_tolerant().unwrap();
679
680 assert_eq!(tolerant.events.len(), 2);
681 assert_eq!(tolerant.events[0].payload["text"], "before");
682 assert_eq!(tolerant.events[1].payload["text"], "after");
683 assert_eq!(tolerant.diagnostics.len(), 1);
684 assert_eq!(tolerant.diagnostics[0].line, 2);
685 assert!(tolerant.diagnostics[0].message.contains("line 2"));
686 assert!(!tolerant.diagnostics[0].message.contains("secret-token"));
687 }
688
689 #[test]
690 fn tolerant_bounded_read_rejects_invalid_utf8_without_replacement_history() {
691 let temp = TempDir::new().unwrap();
692 let manager = SessionManager::new(temp.path().join("sessions"));
693 let session = manager.open("safe").unwrap();
694 let before = SessionEvent::new(
695 "user_input",
696 session.id().to_string(),
697 temp.path().to_path_buf(),
698 json!({"text":"before"}),
699 );
700 let invalid = SessionEvent::new(
701 "assistant_output",
702 session.id().to_string(),
703 temp.path().to_path_buf(),
704 json!({"text":"invalid-secret"}),
705 );
706 let after = SessionEvent::new(
707 "assistant_output",
708 session.id().to_string(),
709 temp.path().to_path_buf(),
710 json!({"text":"after"}),
711 );
712 let mut content = Vec::new();
713 content.extend(serde_json::to_vec(&before).unwrap());
714 content.push(b'\n');
715 content.extend(invalid_utf8_event_line(&invalid, b"invalid-secret"));
716 content.push(b'\n');
717 content.extend(serde_json::to_vec(&after).unwrap());
718 content.push(b'\n');
719 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
720 fs::write(session.path(), &content).unwrap();
721 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
722
723 let tolerant = session
724 .read_events_tolerant_bounded(10, content.len())
725 .unwrap();
726
727 assert_eq!(tolerant.events.len(), 2);
728 assert_eq!(tolerant.events[0].payload["text"], "before");
729 assert_eq!(tolerant.events[1].payload["text"], "after");
730 assert_eq!(tolerant.diagnostics.len(), 1);
731 assert_eq!(tolerant.diagnostics[0].line, 2);
732 assert!(!tolerant.diagnostics[0].message.contains("invalid-secret"));
733 assert!(!tolerant.diagnostics[0].message.contains('\u{fffd}'));
734 }
735
736 #[test]
737 fn tolerant_recent_paths_reject_invalid_utf8_without_replacement_history() {
738 let temp = TempDir::new().unwrap();
739 let manager = SessionManager::new(temp.path().join("sessions"));
740 let session = manager.open("safe").unwrap();
741 let prefix = SessionEvent::new(
742 "user_input",
743 session.id().to_string(),
744 temp.path().to_path_buf(),
745 json!({"text":"prefix"}),
746 );
747 let invalid = SessionEvent::new(
748 "assistant_output",
749 session.id().to_string(),
750 temp.path().to_path_buf(),
751 json!({"text":"invalid-secret"}),
752 );
753 let after = SessionEvent::new(
754 "assistant_output",
755 session.id().to_string(),
756 temp.path().to_path_buf(),
757 json!({"text":"after"}),
758 );
759 let prefix_line = serde_json::to_vec(&prefix).unwrap();
760 let invalid_line = invalid_utf8_event_line(&invalid, b"invalid-secret");
761 let after_line = serde_json::to_vec(&after).unwrap();
762 let mut content = Vec::new();
763 content.extend(&prefix_line);
764 content.push(b'\n');
765 content.extend(&invalid_line);
766 content.push(b'\n');
767 content.extend(&after_line);
768 content.push(b'\n');
769 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
770 fs::write(session.path(), &content).unwrap();
771 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
772
773 let recent = session
774 .read_recent_events_tolerant(10, content.len())
775 .unwrap();
776 assert_eq!(recent.events.len(), 2);
777 assert_eq!(recent.events[0].payload["text"], "prefix");
778 assert_eq!(recent.events[1].payload["text"], "after");
779 assert!(
780 recent
781 .diagnostics
782 .iter()
783 .any(|diagnostic| diagnostic.line == 2)
784 );
785
786 let max_read_bytes = invalid_line.len() + after_line.len() + 4;
787 let tail = session
788 .read_recent_events_tolerant_tail(10, content.len(), max_read_bytes)
789 .unwrap();
790 assert_eq!(tail.events.len(), 1);
791 assert_eq!(tail.events[0].payload["text"], "after");
792 assert!(
793 tail.diagnostics
794 .iter()
795 .any(|diagnostic| diagnostic.line == 1)
796 );
797 assert!(
798 !tail
799 .diagnostics
800 .iter()
801 .any(|diagnostic| diagnostic.message.contains("invalid-secret"))
802 );
803 assert!(
804 !tail
805 .diagnostics
806 .iter()
807 .any(|diagnostic| diagnostic.message.contains('\u{fffd}'))
808 );
809 }
810
811 #[test]
812 fn tolerant_session_read_caps_malformed_diagnostics() {
813 let temp = TempDir::new().unwrap();
814 let manager = SessionManager::new(temp.path().join("sessions"));
815 let session = manager.open("safe").unwrap();
816 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
817 let mut lines = Vec::new();
818 for index in 0..(MAX_TOLERANT_READ_DIAGNOSTICS + 10) {
819 lines.push(format!("not json {index}"));
820 }
821 fs::write(session.path(), lines.join("\n")).unwrap();
822 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
823
824 let tolerant = session.read_events_tolerant().unwrap();
825
826 assert!(tolerant.events.is_empty());
827 assert_eq!(tolerant.diagnostics.len(), MAX_TOLERANT_READ_DIAGNOSTICS);
828 let summary = tolerant.diagnostics.last().unwrap();
829 assert_eq!(summary.line, 0);
830 assert!(summary.message.contains("omitted 10 additional"));
831 assert!(summary.message.contains("cap of 64"));
832 assert!(
833 !tolerant
834 .diagnostics
835 .iter()
836 .any(|diagnostic| diagnostic.message.contains("not json"))
837 );
838 }
839
840 #[test]
841 fn read_recent_events_tolerant_preserves_valid_events_after_malformed_line() {
842 let temp = TempDir::new().unwrap();
843 let manager = SessionManager::new(temp.path().join("sessions"));
844 let session = manager.open("safe").unwrap();
845 let old = SessionEvent::new(
846 "user_input",
847 session.id().to_string(),
848 temp.path().to_path_buf(),
849 json!({"text":"old"}),
850 );
851 let recent = SessionEvent::new(
852 "assistant_output",
853 session.id().to_string(),
854 temp.path().to_path_buf(),
855 json!({"text":"recent"}),
856 );
857 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
858 fs::write(
859 session.path(),
860 format!(
861 "{}\nnot json\n{}\n",
862 serde_json::to_string(&old).unwrap(),
863 serde_json::to_string(&recent).unwrap()
864 ),
865 )
866 .unwrap();
867 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
868
869 let tolerant = session.read_recent_events_tolerant(10, 4096).unwrap();
870
871 assert_eq!(tolerant.events.len(), 2);
872 assert_eq!(tolerant.events[1].payload["text"], "recent");
873 assert_eq!(tolerant.diagnostics[0].line, 2);
874 assert!(!tolerant.diagnostics[0].message.contains("not json"));
875 }
876
877 #[test]
878 fn read_recent_events_tolerant_bounds_malformed_input_bytes() {
879 let temp = TempDir::new().unwrap();
880 let manager = SessionManager::new(temp.path().join("sessions"));
881 let session = manager.open("safe").unwrap();
882 let valid = SessionEvent::new(
883 "user_input",
884 session.id().to_string(),
885 temp.path().to_path_buf(),
886 json!({"text":"valid"}),
887 );
888 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
889 let malformed = "{".repeat(128);
890 fs::write(
891 session.path(),
892 format!("{}\n{malformed}\n", serde_json::to_string(&valid).unwrap()),
893 )
894 .unwrap();
895 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
896
897 let error = session
898 .read_recent_events_tolerant(10, 64)
899 .unwrap_err()
900 .to_string();
901
902 assert!(
903 error.contains("tolerant recent read limit exceeded"),
904 "{error}"
905 );
906 assert!(error.contains("malformed bytes"), "{error}");
907 }
908}