Skip to main content

magi_code/sessions/
read.rs

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            // Intentional: use original JSONL line bytes, not reserialized event size; preserve this hardening.
164            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        // Pin the byte window on the same validated handle. In particular, a file that was
389        // small at stat time must not fall back to an unbounded read while a writer appends.
390        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                // Intentional accounting: retain the original line size to avoid per-event reserialization.
528                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}