Skip to main content

pmcp/shared/
sse_parser.rs

1//! Server-Sent Events (SSE) parser for MCP HTTP transport.
2//!
3//! This module provides a robust SSE parser compatible with the
4//! `EventSource` specification, similar to eventsource-parser in TypeScript.
5
6use std::collections::HashMap;
7use std::fmt;
8
9/// SSE event parsed from the stream.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SseEvent {
12    /// Event ID for resumption
13    pub id: Option<String>,
14    /// Event type/name
15    pub event: Option<String>,
16    /// Event data
17    pub data: String,
18    /// Retry interval in milliseconds
19    pub retry: Option<u64>,
20}
21
22impl SseEvent {
23    /// Create a new SSE event with data.
24    ///
25    /// # Examples
26    ///
27    /// ```rust
28    /// use pmcp::shared::sse_parser::SseEvent;
29    ///
30    /// let event = SseEvent::new("Hello, world!");
31    /// assert_eq!(event.data, "Hello, world!");
32    /// assert!(event.id.is_none());
33    /// assert!(event.event.is_none());
34    /// ```
35    pub fn new(data: impl Into<String>) -> Self {
36        Self {
37            id: None,
38            event: None,
39            data: data.into(),
40            retry: None,
41        }
42    }
43
44    /// Set the event ID.
45    ///
46    /// # Examples
47    ///
48    /// ```rust
49    /// use pmcp::shared::sse_parser::SseEvent;
50    ///
51    /// let event = SseEvent::new("data")
52    ///     .with_id("msg-123");
53    /// assert_eq!(event.id, Some("msg-123".to_string()));
54    /// ```
55    pub fn with_id(mut self, id: impl Into<String>) -> Self {
56        self.id = Some(id.into());
57        self
58    }
59
60    /// Set the event type.
61    ///
62    /// # Examples
63    ///
64    /// ```rust
65    /// use pmcp::shared::sse_parser::SseEvent;
66    ///
67    /// let event = SseEvent::new("data")
68    ///     .with_event("custom");
69    /// assert_eq!(event.event, Some("custom".to_string()));
70    /// ```
71    pub fn with_event(mut self, event: impl Into<String>) -> Self {
72        self.event = Some(event.into());
73        self
74    }
75
76    /// Set the retry interval.
77    ///
78    /// # Examples
79    ///
80    /// ```rust
81    /// use pmcp::shared::sse_parser::SseEvent;
82    ///
83    /// let event = SseEvent::new("data")
84    ///     .with_retry(3000);
85    /// assert_eq!(event.retry, Some(3000));
86    /// ```
87    pub fn with_retry(mut self, retry: u64) -> Self {
88        self.retry = Some(retry);
89        self
90    }
91}
92
93impl fmt::Display for SseEvent {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        if let Some(id) = &self.id {
96            writeln!(f, "id: {}", id)?;
97        }
98        if let Some(event) = &self.event {
99            writeln!(f, "event: {}", event)?;
100        }
101        if let Some(retry) = self.retry {
102            writeln!(f, "retry: {}", retry)?;
103        }
104
105        // Split data by newlines and write each line
106        for line in self.data.lines() {
107            writeln!(f, "data: {}", line)?;
108        }
109
110        writeln!(f)?; // Empty line to end event
111        Ok(())
112    }
113}
114
115/// SSE parser state machine.
116#[derive(Debug)]
117pub struct SseParser {
118    buffer: String,
119    current_event: EventBuilder,
120    last_event_id: Option<String>,
121    /// Upper bound on the parser's total IN-FLIGHT bytes: the unterminated line
122    /// still sitting in `buffer` PLUS the `data:` lines already accumulated into
123    /// `current_event`, which only a BLANK line dispatches.
124    ///
125    /// The bytes fed to a parser are chosen by a REMOTE peer, and NEITHER
126    /// accumulator drains on its own: `buffer` only drains as far as a `\n`, and
127    /// `current_event.data` only drains when the peer sends the blank line that
128    /// ends the event. Bounding `buffer` alone is therefore not a bound at all —
129    /// a peer that streams ordinary newline-terminated `data:` lines forever
130    /// grows this process's heap for as long as it holds the stream open, which
131    /// is the realistic shape of the attack rather than the artificial one
132    /// (verification gap item 3, review CR-01/CR-02, T-113-79).
133    max_buffer_size: usize,
134    /// Latched once oversized in-flight data has been discarded.
135    overflowed: bool,
136}
137
138/// The default bound on a parser's total in-flight bytes (1 MiB).
139///
140/// Single source of truth for [`SseParser::new`] and [`SseConfig::default()`],
141/// so the two can never disagree. Reading it through `SseConfig::default()`
142/// would allocate that struct's `HashMap` and four `String`s just to fetch one
143/// `usize`, on a path taken once per SSE response.
144pub const DEFAULT_MAX_BUFFER_SIZE: usize = 1024 * 1024;
145
146/// Split the longest decodable UTF-8 prefix off `buffer`, leaving the rest.
147///
148/// The companion every INCREMENTAL feeder of [`SseParser`] needs, and the reason
149/// it lives beside the parser rather than beside one of them: a body chunk
150/// boundary can fall in the MIDDLE of a multi-byte character, so a per-chunk
151/// `String::from_utf8_lossy` corrupts any non-ASCII payload (a `file:///café.txt`
152/// resource URI, a non-Latin tool argument) that happens to straddle two frames.
153/// An INCOMPLETE tail is retained for the next chunk; genuinely INVALID bytes are
154/// replaced with U+FFFD immediately, because retaining those forever would wedge
155/// the stream on hostile input (T-113-67).
156///
157/// The two cases are handled INDEPENDENTLY rather than "any invalid byte means
158/// decode the whole buffer lossily": a chunk that carries both an invalid byte
159/// AND a trailing incomplete character would otherwise have that trailing
160/// character replaced too, corrupting a legitimate multi-byte character that the
161/// next chunk was about to complete.
162///
163/// The retained tail is at most 3 bytes, so this cannot grow without bound.
164///
165/// # Linear, not quadratic
166///
167/// The scan advances a `consumed` CURSOR and performs at most ONE `Vec::drain`
168/// (or `clear`) for the whole call. The previous shape re-validated the buffer
169/// from index 0 and `drain`-ed one invalid run per iteration, so a chunk of `n`
170/// invalid bytes cost `O(n^2)` byte moves AND `O(n^2)` validation — a remote
171/// CPU-exhaustion vector on a decoder that is fed directly from an untrusted
172/// peer by both incremental feeders (`connect_sse`'s reader task and the
173/// `subscriptions/listen` client), and one that no byte-count bound limits
174/// because it runs BEFORE the parser sees the text (review CR-02).
175pub(crate) fn take_utf8_prefix(buffer: &mut Vec<u8>) -> String {
176    let mut text = String::new();
177    // Bytes already appended to `text` (as themselves or as U+FFFD). Never
178    // removed from `buffer` until the single exit-point drain below, so the
179    // remaining slice is only ever validated once end-to-end.
180    let mut consumed = 0usize;
181    loop {
182        let rest = &buffer[consumed..];
183        let error = match std::str::from_utf8(rest) {
184            Ok(valid) => {
185                text.push_str(valid);
186                buffer.clear();
187                return text;
188            },
189            Err(error) => error,
190        };
191        let valid_up_to = error.valid_up_to();
192        // `Utf8Error::valid_up_to()` is DEFINED as the length of the verified-valid
193        // prefix, so this re-validation cannot fail; the `else` arm was unreachable.
194        // The second pass itself is unavoidable under `#![deny(unsafe_code)]`.
195        text.push_str(std::str::from_utf8(&rest[..valid_up_to]).unwrap_or_default());
196        let Some(invalid_len) = error.error_len() else {
197            // "Unexpected end of input": an incomplete character the next chunk
198            // will finish. Keep exactly those bytes and yield what decoded.
199            buffer.drain(..consumed + valid_up_to);
200            return text;
201        };
202        // Never completable — emit the replacement character and skip past it.
203        text.push('\u{FFFD}');
204        consumed += valid_up_to + invalid_len;
205    }
206}
207
208impl SseParser {
209    /// Create a new SSE parser bounded by [`DEFAULT_MAX_BUFFER_SIZE`] (1 MiB),
210    /// the same value [`SseConfig::default()`] carries.
211    ///
212    /// # Examples
213    ///
214    /// ```rust
215    /// use pmcp::shared::sse_parser::SseParser;
216    ///
217    /// let mut parser = SseParser::new();
218    /// assert!(parser.last_event_id().is_none());
219    /// assert!(!parser.overflowed());
220    /// ```
221    pub fn new() -> Self {
222        Self::with_max_buffer_size(DEFAULT_MAX_BUFFER_SIZE)
223    }
224
225    /// Create a new SSE parser with an explicit in-flight bound.
226    ///
227    /// [`SseParser::new`] takes its bound from [`SseConfig::default()`]'s
228    /// `max_buffer_size`. Use this constructor when a caller needs a TIGHTER
229    /// one — a long-lived stream of small frames read from an untrusted remote
230    /// peer, for example — or a looser one.
231    ///
232    /// The bound applies to everything the parser RETAINS across calls: the
233    /// unterminated line in its buffer plus the `data:` lines already
234    /// accumulated into the event still awaiting its blank line. A chunk that
235    /// would push that total past the bound is DISCARDED, not
236    /// truncated-and-emitted, and [`Self::overflowed`] latches: a silently
237    /// truncated line would surface later as a misleading JSON parse failure,
238    /// which is strictly worse for an operator than a named one.
239    ///
240    /// # Examples
241    ///
242    /// ```rust
243    /// use pmcp::shared::sse_parser::SseParser;
244    ///
245    /// let mut parser = SseParser::with_max_buffer_size(64);
246    ///
247    /// // A peer that never sends a newline cannot grow this parser's heap.
248    /// assert!(parser.feed(&"x".repeat(1024)).is_empty());
249    /// assert!(parser.overflowed());
250    ///
251    /// // The parser keeps working — but the flag stays latched.
252    /// let events = parser.feed("data: ok\n\n");
253    /// assert_eq!(events[0].data, "ok");
254    /// assert!(parser.overflowed());
255    /// ```
256    ///
257    /// Neither does a peer that sends perfectly ordinary NEWLINE-TERMINATED
258    /// `data:` lines and simply never ends the event with a blank line — the
259    /// realistic shape of the same attack, and the one a bound over the line
260    /// buffer alone does not stop:
261    ///
262    /// ```rust
263    /// use pmcp::shared::sse_parser::SseParser;
264    ///
265    /// let mut parser = SseParser::with_max_buffer_size(64);
266    /// for _ in 0..1_000 {
267    ///     // Every chunk is well-formed and ends in a newline.
268    ///     assert!(parser.feed("data: AAAAAAAA\n").is_empty());
269    /// }
270    /// assert!(parser.overflowed());
271    /// ```
272    #[must_use]
273    pub fn with_max_buffer_size(max_buffer_size: usize) -> Self {
274        Self {
275            buffer: String::new(),
276            current_event: EventBuilder::new(),
277            last_event_id: None,
278            max_buffer_size,
279            overflowed: false,
280        }
281    }
282
283    /// Whether this parser has DISCARDED oversized IN-FLIGHT data.
284    ///
285    /// LATCHING: once set it stays set for the parser's lifetime — including
286    /// across [`Self::reset`] — so a caller that polls once per chunk cannot
287    /// miss the event. An overflowed parser has lost bytes a remote peer sent,
288    /// so its stream should be considered CORRUPT: the recommended response is
289    /// to end the stream with an error naming the limit, not to keep parsing.
290    ///
291    /// # Examples
292    ///
293    /// ```rust
294    /// use pmcp::shared::sse_parser::SseParser;
295    ///
296    /// let mut parser = SseParser::new();
297    /// let _ = parser.feed("data: a well-formed event\n\n");
298    /// assert!(!parser.overflowed());
299    /// ```
300    #[must_use]
301    pub fn overflowed(&self) -> bool {
302        self.overflowed
303    }
304
305    /// The bound THIS parser was built with, in bytes.
306    ///
307    /// A caller reporting an overflow should name this rather than re-deriving
308    /// a bound from config: parsers on different paths are deliberately built
309    /// with different bounds, so a re-derived number can name a limit the
310    /// parser never had.
311    ///
312    /// # Examples
313    ///
314    /// ```rust
315    /// use pmcp::shared::sse_parser::SseParser;
316    ///
317    /// assert_eq!(SseParser::with_max_buffer_size(64).max_buffer_size(), 64);
318    /// ```
319    #[must_use]
320    pub fn max_buffer_size(&self) -> usize {
321        self.max_buffer_size
322    }
323
324    /// The bytes this parser retains ACROSS lines, i.e. the two accumulators
325    /// [`Self::max_buffer_size`] bounds: the unterminated line in the buffer plus
326    /// the `data:` payload of the event still awaiting its blank line.
327    ///
328    /// NOT "total retained bytes", and deliberately not named that. It EXCLUDES
329    /// `current_event.id`, `current_event.event` and `last_event_id`, and an
330    /// `id:` value is in fact retained TWICE (once in each of the first and last
331    /// of those). Those three fields are each ASSIGNED from a single line rather
332    /// than APPENDED to, and a line can only reach `process_line` if the chunk
333    /// carrying it satisfied the bound, so each is itself bounded by roughly
334    /// `max_buffer_size` and none of them can grow with stream age. The true
335    /// ceiling on a parser is therefore a small constant multiple of
336    /// `max_buffer_size` rather than exactly it.
337    ///
338    /// What this function measures is the only pair of quantities that
339    /// ACCUMULATE across lines — which is the property that matters for a
340    /// long-lived stream, and the one the pre-113-17 bound violated.
341    ///
342    /// `pub(crate)` deliberately: its consumers are this module's tests,
343    /// [`crate::client`]'s `subscriptions` module and (through that module) the
344    /// fuzz seam. It is diagnostic detail, not shipped public API.
345    pub(crate) fn buffered_bytes(&self) -> usize {
346        self.buffer.len() + self.current_event.data.len()
347    }
348
349    /// Feed data to the parser and get parsed events.
350    ///
351    /// The parser retains at most [`Self::max_buffer_size`] bytes across its two
352    /// accumulators on return; see `Self::buffered_bytes`. A chunk that would
353    /// break that is refused whole and [`Self::overflowed`] latches.
354    ///
355    /// This is the ONLY entry point. The unbounded complete-body sibling that
356    /// used to sit beside it was retired in Phase 118.2 once both of its callers
357    /// became incremental readers — see the note where it lived.
358    ///
359    /// # Examples
360    ///
361    /// ```rust
362    /// use pmcp::shared::sse_parser::SseParser;
363    ///
364    /// let mut parser = SseParser::new();
365    ///
366    /// // Simple event
367    /// let events = parser.feed("data: Hello\n\n");
368    /// assert_eq!(events.len(), 1);
369    /// assert_eq!(events[0].data, "Hello");
370    ///
371    /// // Event with ID
372    /// let events = parser.feed("id: 123\ndata: World\n\n");
373    /// assert_eq!(events[0].id, Some("123".to_string()));
374    /// assert_eq!(events[0].data, "World");
375    ///
376    /// // Multi-line data
377    /// let events = parser.feed("data: Line 1\ndata: Line 2\n\n");
378    /// assert_eq!(events[0].data, "Line 1\nLine 2");
379    ///
380    /// // Custom event type
381    /// let events = parser.feed("event: ping\ndata: pong\n\n");
382    /// assert_eq!(events[0].event, Some("ping".to_string()));
383    /// ```
384    pub fn feed(&mut self, data: &str) -> Vec<SseEvent> {
385        // A REMOTE peer decides every byte here, and BOTH of the parser's
386        // accumulators are peer-drained: `buffer` empties only as far as a `\n`,
387        // and `current_event.data` empties only when the peer sends the blank
388        // line that dispatches the event. Bound their SUM, unconditionally: when
389        // appending `data` would push the retained total past `max_buffer_size`,
390        // DISCARD what is held and latch `overflowed` rather than grow.
391        //
392        // There is deliberately no "unless this chunk carries a newline" escape.
393        // That escape used to be here, on the theory that a chunk containing a
394        // newline completes a line and therefore cannot accumulate — but a
395        // `data:` line accumulates into the EVENT, which no newline ends. Only a
396        // BLANK line does. `feed("data: AAAAAAAA\n")` repeated 100 000 times
397        // under a 64-byte bound reached 899 999 retained bytes with the flag
398        // clear (review CR-01/CR-02, verification gap item 3).
399        //
400        // The bound is over RETAINED STATE PLUS THIS CHUNK, not over "one
401        // in-progress event". A chunk carrying many individually small COMPLETE
402        // events is refused whenever the chunk TOTAL exceeds the limit, so
403        // behaviour depends partly on how the transport frames its reads. That
404        // is accepted rather than fixed: evaluating the bound only over what
405        // survives after splitting the complete events out of the chunk would
406        // require parsing the whole unbounded chunk first — performing exactly
407        // the allocation the bound exists to prevent (review MEDIUM-1,
408        // T-113-86). A caller holding a COMPLETE body raises the BOUND for its
409        // parser rather than reaching for an unbounded entry point; there is no
410        // longer one to reach for.
411        if self.buffered_bytes().saturating_add(data.len()) > self.max_buffer_size {
412            self.overflowed = true;
413            self.buffer.clear();
414            // The in-progress event is now missing a line; anything built from
415            // it would be a corrupted frame presented to the caller as genuine.
416            self.current_event = EventBuilder::new();
417            // `last_event_id` is deliberately untouched: it is stream-level
418            // resumption state, not line state.
419            return Vec::new();
420        }
421
422        let events = self.drain_complete_lines(data);
423
424        // A SECOND, independently sufficient enforcement point over the RESIDUAL
425        // this call actually leaves behind. Before the pre-check became
426        // unconditional this caught the chunk that begins with `\n` and then
427        // carries megabytes of newline-free bytes; it is kept because the two
428        // checks fail independently, and a future change that weakens one must
429        // still meet the other. It covers the event accumulator as well as the
430        // line buffer, so the invariant that holds on RETURN from `feed` is
431        // `buffered_bytes() <= max_buffer_size`.
432        if self.buffered_bytes() > self.max_buffer_size {
433            self.overflowed = true;
434            self.buffer.clear();
435            self.current_event = EventBuilder::new();
436        }
437
438        events
439    }
440
441    // ── Retired: the complete-body entry point (Phase 118.2, plan 03) ────────
442    //
443    // There used to be a `feed_complete_body` here — an UNBOUNDED sibling of
444    // `feed` that parsed an already-collected body in one piece, stating its byte
445    // cap as a precondition on the caller rather than enforcing one. It existed
446    // to serve the two whole-body collects in `StreamableHttpTransport`: the GET
447    // session stream and the POST response that answers `text/event-stream`.
448    // Phase 118.2 turned BOTH of those into incremental reads under `feed`'s
449    // bound (D-01/D-02), which left it with no caller at all.
450    //
451    // It was DELETED rather than kept, because a parser entry point that performs
452    // no bound check and has nobody to serve is exactly the attractive nuisance
453    // its own documentation warned about: the next caller inherits an obligation
454    // stated only in prose, and `feed` is now correct for every case it covered.
455
456    /// Append `data` and drain every COMPLETE line out of the buffer.
457    ///
458    /// The tokenizer behind [`Self::feed`], which bound-checks before calling
459    /// it. Kept as a separate function so the bound and the tokenization stay
460    /// visibly distinct concerns.
461    fn drain_complete_lines(&mut self, data: &str) -> Vec<SseEvent> {
462        // # Invariant — the retained buffer is NEWLINE-FREE on entry
463        //
464        // The drain loop below runs to exhaustion and `process_line` never
465        // touches `buffer`, so every call RETURNS with a newline-free buffer and
466        // it starts empty — an unterminated line is all it can ever hold on
467        // entry.
468        //
469        // This is now the SOUNDNESS PRECONDITION for `scan_start` below, not
470        // merely a comment: the search skips the retained prefix entirely, which
471        // can only miss a line if no `'\n'` can hide in that prefix.
472        //
473        // It used to be a `debug_assert!(!self.buffer.contains('\n'))`. That
474        // assertion was itself an O(retained-length) scan on EVERY call, so it
475        // kept this function quadratic in debug builds — precisely the profile
476        // `cargo test` and the 1-byte-chunk proptest run in, which would have
477        // left the new guards RED after a correct fix. Deleted in plan 113.1-02
478        // (D-15); the invariant is pinned instead by the named test
479        // `drain_leaves_no_newline_in_the_retained_buffer`, which covers feeds of
480        // every shape including a CRLF split across a chunk boundary.
481        let scan_start = self.buffer.len();
482        self.buffer.push_str(data);
483        let mut events = Vec::new();
484
485        // LINEAR, not quadratic — in TWO distinct senses, fixed in two rounds.
486        //
487        // Per-LINE (fixed in `0493d9fb`): a `drain(..=line_end)` per line memmoves
488        // the whole remaining buffer once per line, so one chunk carrying `n`
489        // lines cost `O(n * len)` byte moves. A `consumed` cursor advances instead
490        // and the single drain below runs once for the whole call, the same shape
491        // `take_utf8_prefix` above uses for the same reason.
492        //
493        // Per-CALL (D-113-R, fixed in plan 113.1-02): the search itself used to
494        // restart at offset 0 on every call, re-scanning the whole retained prefix
495        // that every earlier call had already scanned. A peer chooses the chunk
496        // framing, so a peer chose how many times its bytes got re-scanned —
497        // 1-byte chunks cost one full-buffer scan per byte. `search_from` starts
498        // at `scan_start`, the length BEFORE this chunk was appended, so each call
499        // scans only the bytes it just added. Sound because the retained prefix is
500        // newline-free (see the invariant above).
501        //
502        // `consumed` MUST stay initialised to 0, not to `scan_start`: it drives
503        // both the final `drain(..consumed)` and the CRLF lookback guard. When the
504        // retained prefix ends with `\r` and this chunk begins with `\n`,
505        // `line_end == scan_start` and `line_end - 1` points INTO the retained
506        // prefix; the guard `line_end > consumed` is then `scan_start > 0`, which
507        // is true, so the `\r` is correctly trimmed. Seeding `consumed` to
508        // `scan_start` would make that guard false and leak the `\r` into the line.
509        let mut consumed = 0usize;
510        let mut search_from = scan_start;
511        while let Some(offset) = self.buffer[search_from..].find('\n') {
512            let line_end = search_from + offset;
513            // `line_end` is a BYTE index (`str::find` returns one). The CRLF
514            // check must therefore also be a BYTE check.
515            //
516            // It used to read `self.buffer.chars().nth(line_end - 1)`, which
517            // indexes by CHARACTER. On any buffer containing a multi-byte
518            // character the two disagree, so the check could report `'\r'` for
519            // a position that is not byte `line_end - 1`, and the slice that
520            // followed (`self.buffer[..line_end - 1]`) then cut INSIDE a
521            // character and PANICKED — on bytes supplied by a remote server.
522            // Found by `client::subscriptions`'s arbitrary-bytes property test
523            // (Phase 113-13, T-113-67); `feed_never_panics_on_arbitrary_text`
524            // below is the permanent guard.
525            //
526            // `\n` and `\r` are ASCII, so both `line_end` and `line_end - 1`
527            // (taken only when that byte IS `\r`) are guaranteed char
528            // boundaries. The `> consumed` guard — not `> 0` — keeps the lookback
529            // inside THIS line, so an empty line never reads the previous line's
530            // terminator.
531            let line_start_len =
532                if line_end > consumed && self.buffer.as_bytes()[line_end - 1] == b'\r' {
533                    line_end - 1
534                } else {
535                    line_end
536                };
537            let line = self.buffer[consumed..line_start_len].to_string();
538
539            if let Some(event) = self.process_line(&line) {
540                events.push(event);
541            }
542
543            consumed = line_end + 1;
544            search_from = consumed;
545        }
546        self.buffer.drain(..consumed);
547
548        events
549    }
550
551    /// Process a single line and potentially emit an event.
552    fn process_line(&mut self, line: &str) -> Option<SseEvent> {
553        // Empty line dispatches the event
554        if line.is_empty() {
555            return self.dispatch_event();
556        }
557
558        // Comment line (starts with :)
559        if line.starts_with(':') {
560            return None;
561        }
562
563        // Parse field and value
564        let (field, value) = if let Some(colon_pos) = line.find(':') {
565            let field = &line[..colon_pos];
566            let value = &line[colon_pos + 1..];
567            // Remove leading space from value if present
568            let value = value.strip_prefix(' ').unwrap_or(value);
569            (field, value)
570        } else {
571            // Field without value
572            (line, "")
573        };
574
575        // Process field
576        match field {
577            "event" => {
578                self.current_event.event = Some(value.to_string());
579            },
580            "data" => {
581                if self.current_event.data.is_empty() {
582                    self.current_event.data = value.to_string();
583                } else {
584                    self.current_event.data.push('\n');
585                    self.current_event.data.push_str(value);
586                }
587            },
588            "id" if !value.contains('\0') => {
589                self.current_event.id = Some(value.to_string());
590                self.last_event_id = Some(value.to_string());
591            },
592            "retry" => {
593                if let Ok(retry) = value.parse::<u64>() {
594                    self.current_event.retry = Some(retry);
595                }
596            },
597            _ => {
598                // Unknown field, ignore
599            },
600        }
601
602        None
603    }
604
605    /// Dispatch the current event if it has data.
606    fn dispatch_event(&mut self) -> Option<SseEvent> {
607        if self.current_event.data.is_empty() {
608            // No data, don't dispatch
609            self.current_event = EventBuilder::new();
610            return None;
611        }
612
613        let event = SseEvent {
614            id: self
615                .current_event
616                .id
617                .clone()
618                .or_else(|| self.last_event_id.clone()),
619            event: self.current_event.event.clone(),
620            data: self.current_event.data.clone(),
621            retry: self.current_event.retry,
622        };
623
624        self.current_event = EventBuilder::new();
625        Some(event)
626    }
627
628    /// Get the last event ID seen.
629    pub fn last_event_id(&self) -> Option<&str> {
630        self.last_event_id.as_deref()
631    }
632
633    /// Reset the parser state.
634    ///
635    /// Clears the line buffer and any in-progress event. It deliberately does
636    /// NOT clear [`Self::overflowed`], which records that bytes a peer sent were
637    /// already LOST — a fact resetting the line state cannot undo.
638    pub fn reset(&mut self) {
639        self.buffer.clear();
640        self.current_event = EventBuilder::new();
641    }
642}
643
644impl Default for SseParser {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650/// Builder for SSE events during parsing.
651#[derive(Debug, Clone)]
652struct EventBuilder {
653    id: Option<String>,
654    event: Option<String>,
655    data: String,
656    retry: Option<u64>,
657}
658
659impl EventBuilder {
660    fn new() -> Self {
661        Self {
662            id: None,
663            event: None,
664            data: String::new(),
665            retry: None,
666        }
667    }
668}
669
670/// SSE stream builder for creating SSE responses.
671#[derive(Debug)]
672pub struct SseStream {
673    events: Vec<SseEvent>,
674}
675
676impl SseStream {
677    /// Create a new SSE stream builder.
678    pub fn new() -> Self {
679        Self { events: Vec::new() }
680    }
681
682    /// Add an event to the stream.
683    pub fn event(mut self, event: SseEvent) -> Self {
684        self.events.push(event);
685        self
686    }
687
688    /// Add a simple data event.
689    pub fn data(self, data: impl Into<String>) -> Self {
690        self.event(SseEvent::new(data))
691    }
692
693    /// Add a typed event with data.
694    pub fn typed_event(self, event_type: impl Into<String>, data: impl Into<String>) -> Self {
695        self.event(SseEvent::new(data).with_event(event_type))
696    }
697
698    /// Add a comment line.
699    pub fn comment(self, _comment: impl Into<String>) -> Self {
700        // Comments are not stored as events, they're just for keep-alive
701        // In a real implementation, we'd write this directly to the stream
702        self
703    }
704
705    /// Build the SSE stream as a string.
706    pub fn build(self) -> String {
707        self.events
708            .into_iter()
709            .map(|e| e.to_string())
710            .collect::<String>()
711    }
712}
713
714impl Default for SseStream {
715    fn default() -> Self {
716        Self::new()
717    }
718}
719
720/// Configuration for SSE connections.
721#[derive(Debug, Clone)]
722pub struct SseConfig {
723    /// Reconnection retry interval in milliseconds
724    pub retry: u64,
725    /// Maximum in-flight bytes a parser may retain.
726    ///
727    /// [`SseParser::new`] takes its bound from this field's DEFAULT value, and
728    /// [`SseParser::with_max_buffer_size`] overrides it per parser. It covers
729    /// BOTH of the parser's accumulators — the unterminated line in its buffer
730    /// and the `data:` payload of the event still awaiting its blank line. A
731    /// chunk that would push that total past the bound is discarded whole and
732    /// latches [`SseParser::overflowed`], so no peer can grow the process's heap
733    /// without limit by holding a stream open, whether or not it sends newlines.
734    pub max_buffer_size: usize,
735    /// Enable compression
736    pub compression: bool,
737    /// Custom headers
738    pub headers: HashMap<String, String>,
739}
740
741impl Default for SseConfig {
742    fn default() -> Self {
743        let mut headers = HashMap::new();
744        headers.insert("Cache-Control".to_string(), "no-cache".to_string());
745        headers.insert("Connection".to_string(), "keep-alive".to_string());
746
747        Self {
748            retry: 3000,
749            max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
750            compression: false,
751            headers,
752        }
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    //! # Which ALWAYS requirement each piece of plan 113-22 discharges
759    //!
760    //! CLAUDE.md asks every change for FUZZ, PROPERTY, UNIT and EXAMPLE. Stated
761    //! here explicitly rather than left to a reviewer to reconstruct:
762    //!
763    //! - **PROPERTY** — `property_take_utf8_prefix_retains_at_most_a_three_byte_tail`,
764    //!   which proves the ≤3-byte retained-tail bound over arbitrary bytes rather
765    //!   than over the three fixtures it used to rest on.
766    //! - **UNIT** — the two budget tests, the growth-ratio test, the retained-tail
767    //!   companion and the pre-existing output test.
768    //! - **FUZZ** — the EXISTING `fuzz_listen_frames` target (113-16), which already
769    //!   drives this decoder through `decode_listen_chunks_for_fuzz`. No new target
770    //!   is added, and the reason is recorded rather than assumed: D-113-G notes
771    //!   that `make quality-gate`'s fuzz stage currently builds 0 of 17 targets and
772    //!   swallows failures. That is a known, separately-owned defect; adding an
773    //!   18th target that also would not build would be motion, not coverage, and
774    //!   this plan does not adopt D-113-G.
775    //! - **EXAMPLE** — none. This plan ships no new feature surface, only guards on
776    //!   existing behaviour, so there is nothing for an example to demonstrate.
777    //!   Said plainly instead of inventing one to satisfy a checklist.
778
779    use super::*;
780    use std::time::{Duration, Instant};
781
782    /// Run `body` `runs` times and return the MINIMUM elapsed wall-clock time.
783    ///
784    /// The minimum, never the mean, and never a single sample: it is the
785    /// statistic robust to scheduler preemption on a loaded machine, and — the
786    /// property that makes it sound for a complexity guard — noise can only push
787    /// a measurement UP. Every run pays the same asymptotic cost, so a quadratic
788    /// shape cannot get lucky its way under a ceiling, while a linear one that
789    /// happened to be descheduled gets a second chance.
790    fn min_elapsed(runs: usize, mut body: impl FnMut()) -> Duration {
791        assert!(runs > 0, "a minimum over zero runs is not a measurement");
792        let mut best = Duration::MAX;
793        for _ in 0..runs {
794            let start = Instant::now();
795            body();
796            best = best.min(start.elapsed());
797        }
798        best
799    }
800
801    /// `take_utf8_prefix` agrees with `String::from_utf8_lossy` on a buffer
802    /// that carries valid text, invalid runs and a trailing INCOMPLETE
803    /// character — the three cases the cursor rewrite has to keep distinct.
804    #[test]
805    fn take_utf8_prefix_decodes_mixed_valid_and_invalid_runs() {
806        // "a" | 0xff 0xfe (two independent invalid bytes) | "b☂" | 0xE2 0x98
807        // (the first two bytes of a three-byte character).
808        let mut buffer: Vec<u8> = Vec::new();
809        buffer.push(b'a');
810        buffer.extend_from_slice(&[0xff, 0xfe]);
811        buffer.extend_from_slice("b\u{2602}".as_bytes());
812        buffer.extend_from_slice(&[0xE2, 0x98]);
813
814        let text = take_utf8_prefix(&mut buffer);
815        assert_eq!(text, "a\u{FFFD}\u{FFFD}b\u{2602}");
816        assert_eq!(
817            buffer,
818            vec![0xE2, 0x98],
819            "only the incomplete trailing character is retained"
820        );
821
822        // The next chunk completes it, and nothing was lost across the split.
823        buffer.push(0x82);
824        assert_eq!(take_utf8_prefix(&mut buffer), "\u{2602}");
825        assert_eq!(buffer, [] as [u8; 0]);
826    }
827
828    /// The OUTPUT of a LARGE invalid-byte run (review CR-02).
829    ///
830    /// The bytes here are supplied by a remote peer on both incremental feeders,
831    /// and this decoder runs BEFORE any of the parser's byte-count bounds apply,
832    /// so a per-invalid-byte `Vec::drain` (plus a re-validation from index 0)
833    /// was a remote CPU-exhaustion vector. This test pins the OUTPUT rather than
834    /// a wall-clock number, so it is deterministic on any machine — and that
835    /// output pin is worth having on its own: it is what a decoder that got
836    /// "fast" by decoding LESS fails.
837    ///
838    /// # Correction — this test is NOT the falsifiable complexity guard
839    ///
840    /// It used to end "…while the input size is what makes a reintroduced
841    /// quadratic shape hang the suite instead of passing it". That claim is
842    /// arithmetically false, and it is recorded here rather than deleted so the
843    /// next reader knows which guard is load-bearing. Quadratic cost scales with
844    /// n², and review CR-02 measured the pre-`5f045086` shape at 1.17 s for
845    /// 400 KiB, so the 256 KiB here costs about half a second — this test PASSES,
846    /// in half a second, on the exact defect it named. That is not a deduction:
847    /// plan 113-22 restored the quadratic shape and watched this test go green in
848    /// **0.54 s** while the two budget tests below failed. Re-measured at that
849    /// execution, at `opt-level = 0` (how `cargo nextest` builds this module):
850    /// 256 KiB costs 9.2 ms on the committed cursor scan and 514 ms on the
851    /// restored quadratic shape. Both are under any tolerable ceiling, so no
852    /// timing assertion at THIS size could separate them.
853    ///
854    /// The falsifiable guards are `take_utf8_prefix_stays_within_its_linear_time_budget`
855    /// and `take_utf8_prefix_cost_grows_linearly_not_quadratically` below, which
856    /// move to 1 MiB — where the same two shapes cost 32 ms and 7.9 s (HTTP-09).
857    #[test]
858    fn take_utf8_prefix_is_linear_over_a_large_invalid_run() {
859        const GARBAGE: usize = 256 * 1024;
860        let mut buffer = vec![0xffu8; GARBAGE];
861        buffer.extend_from_slice(b"data: ok\n\n");
862
863        let text = take_utf8_prefix(&mut buffer);
864        assert!(buffer.is_empty(), "nothing completable is retained");
865        assert_eq!(
866            text.chars().filter(|c| *c == '\u{FFFD}').count(),
867            GARBAGE,
868            "one replacement character per invalid byte, exactly as from_utf8_lossy"
869        );
870        assert!(text.ends_with("data: ok\n\n"), "the valid tail survives");
871    }
872
873    /// The LOAD-BEARING complexity guard for `take_utf8_prefix` (HTTP-09).
874    ///
875    /// HTTP-09 asks that no scan over peer-chosen input be worse than O(n). An
876    /// output assertion cannot say that, and neither can a timing assertion at a
877    /// size where both shapes finish quickly. This one is sized so that the two
878    /// shapes land on OPPOSITE sides of the ceiling.
879    ///
880    /// # Why 1 MiB and why one second
881    ///
882    /// Measured at `opt-level = 0` (the profile `cargo nextest run` builds this
883    /// module with — the crate sets no `[profile.dev]` override) on the plan
884    /// 113-22 development machine, over 1 MiB of `0xFF`:
885    ///
886    /// | shape | cost | vs. this ceiling |
887    /// |---|---|---|
888    /// | committed single-pass cursor scan | 32 ms | 31x UNDER |
889    /// | pre-`5f045086`: re-validate from 0, one `Vec::drain` per invalid run | 7.9 s | 7.9x OVER |
890    ///
891    /// The gap between those two is the whole test, and BOTH rows were measured,
892    /// the second by restoring the old shape and watching this test fail at
893    /// 9.39 s. A machine would have to be 31x slower than this one at a
894    /// single-pass 1 MiB scan to fail here spuriously — and that is against the
895    /// MINIMUM of three runs, so it would have to be 31x slower every time, not
896    /// once. Conversely a machine 7.9x FASTER than this one would still fail on
897    /// the quadratic shape. The margins are asymmetric and both are written down
898    /// on purpose: this is a wall-clock assertion, and the honest defence of one
899    /// is its measurement, not its round number.
900    ///
901    /// The 1 MiB is also where quadratic cost stops being survivable: at 256 KiB
902    /// — the size `take_utf8_prefix_is_linear_over_a_large_invalid_run` uses —
903    /// the quadratic shape costs 514 ms and passes everything.
904    #[test]
905    fn take_utf8_prefix_stays_within_its_linear_time_budget() {
906        const GARBAGE: usize = 1024 * 1024;
907        const CEILING: Duration = Duration::from_secs(1);
908
909        let best = min_elapsed(3, || {
910            // A fresh buffer per run: the call consumes it.
911            let mut buffer = vec![0xffu8; GARBAGE];
912            let text = take_utf8_prefix(&mut buffer);
913            std::hint::black_box((&text, &buffer));
914        });
915
916        // Emit the measurement even on success (`--success-output=immediate` or
917        // `--nocapture` to see it). A wall-clock guard whose margin is invisible
918        // until the day it fails is a guard nobody can maintain.
919        eprintln!("take_utf8_prefix: {GARBAGE} invalid bytes in {best:?} (ceiling {CEILING:?})");
920
921        assert!(
922            best < CEILING,
923            "take_utf8_prefix took {best:?} (minimum of 3 runs) over {GARBAGE} bytes of \
924             invalid input; the ceiling is {CEILING:?}.\n\
925             \n\
926             This excludes ONE shape: re-validating the buffer from index 0 each \
927             iteration and performing one `Vec::drain` per invalid run, which is \
928             O(n^2) byte moves. Measured at opt-level 0: that shape costs ~7.9 s here \
929             (9.39 s when this very assertion was run against it) and the committed \
930             single-pass cursor scan costs ~32 ms, so this ceiling sits ~31x above \
931             linear and ~7.9x below quadratic.\n\
932             \n\
933             A measurement in the upper region means the quadratic shape is back, not \
934             that the machine is slow — no machine that can run this suite needs a \
935             second to scan 1 MiB once. Do NOT raise this number to make the test \
936             pass; that converts the guard back into the unfalsifiable one this \
937             replaced (review CR-02, plan 113-22)."
938        );
939
940        // A time budget alone would also be satisfied by a decoder that got fast
941        // by decoding LESS. Pin the output at the budget size too, so "fast" has
942        // to mean "fast AND still correct".
943        let mut buffer = vec![0xffu8; GARBAGE];
944        let text = take_utf8_prefix(&mut buffer);
945        assert!(
946            buffer.is_empty(),
947            "nothing completable is retained at 1 MiB"
948        );
949        assert_eq!(
950            text.chars().filter(|c| *c == '\u{FFFD}').count(),
951            GARBAGE,
952            "one replacement character per invalid byte, at the budget size too"
953        );
954    }
955
956    /// The SECONDARY signal: cost must grow with the input, not with its square.
957    ///
958    /// A 4x input step predicts ~4x time under O(n) and ~16x under O(n^2), so a
959    /// ceiling of 8x separates them with roughly equal headroom on both sides.
960    /// This is machine-independent in a way the absolute budget is not — it
961    /// cancels the machine's constant factor — but it is the WEAKER of the two,
962    /// because a ratio measured on two short samples is a ratio of two noise
963    /// floors. Hence the resolution guard below, and hence the absolute budget
964    /// stays load-bearing.
965    #[test]
966    fn take_utf8_prefix_cost_grows_linearly_not_quadratically() {
967        const SMALL: usize = 256 * 1024;
968        const BIG: usize = 4 * SMALL;
969        // Below this, the measurement is dominated by timer resolution and
970        // allocator warm-up rather than by the scan.
971        const RESOLUTION_FLOOR: Duration = Duration::from_micros(50);
972        const MAX_RATIO: f64 = 8.0;
973
974        let measure = |bytes: usize| {
975            min_elapsed(5, || {
976                let mut buffer = vec![0xffu8; bytes];
977                let text = take_utf8_prefix(&mut buffer);
978                std::hint::black_box((&text, &buffer));
979            })
980        };
981
982        let small = measure(SMALL);
983        let big = measure(BIG);
984
985        if small < RESOLUTION_FLOOR {
986            eprintln!(
987                "take_utf8_prefix_cost_grows_linearly_not_quadratically: SKIPPING the \
988                 ratio assertion. The {SMALL}-byte measurement is {small:?}, under the \
989                 {RESOLUTION_FLOOR:?} floor, so the ratio would be asserted on noise. \
990                 The absolute budget in \
991                 take_utf8_prefix_stays_within_its_linear_time_budget is unaffected and \
992                 remains load-bearing, so the guard does not degrade to nothing."
993            );
994            return;
995        }
996
997        let ratio = big.as_secs_f64() / small.as_secs_f64();
998        eprintln!(
999            "take_utf8_prefix growth: {SMALL} -> {small:?}, {BIG} -> {big:?}, \
1000             ratio {ratio:.2}x (ceiling {MAX_RATIO:.1}x)"
1001        );
1002        assert!(
1003            ratio <= MAX_RATIO,
1004            "take_utf8_prefix cost grew {ratio:.1}x for a 4x input step \
1005             ({SMALL} bytes -> {small:?}, {BIG} bytes -> {big:?}); the ceiling is \
1006             {MAX_RATIO:.1}x.\n\
1007             \n\
1008             O(n) predicts ~4x (measured 3.46x on the committed shape: 9.18 ms -> \
1009             31.8 ms). O(n^2) predicts ~16x (measured 15.38x on the restored \
1010             pre-`5f045086` shape: 514 ms -> 7.91 s). A ratio in \
1011             the upper region is the quadratic shape, not scheduling noise — this is a \
1012             minimum over 5 runs at each size, and noise raises a minimum only by \
1013             raising every sample."
1014        );
1015    }
1016
1017    /// The same budget on the PUBLIC entry point, over its retained-state path.
1018    ///
1019    /// `SseParser::feed` is what a peer actually drives (review WR-04), and the
1020    /// path with an accumulation risk is the one where nothing completes: 256
1021    /// newline-free chunks, so every byte is retained and no event dispatches.
1022    ///
1023    /// # This is a CEILING, not a complexity proof — and D-113-R is why
1024    ///
1025    /// Stated plainly, because an undocumented margin is how the guard this plan
1026    /// replaced went wrong.
1027    ///
1028    /// `feed`'s retained-state path is ALREADY quadratic in total bytes, and
1029    /// plan 113-22 measured it rather than assuming otherwise. Each call runs
1030    /// `str::find('\n')` over the WHOLE retained buffer, including the prefix
1031    /// every earlier call already scanned; a peer chooses the chunking, so a peer
1032    /// chooses how many times its bytes get re-scanned. Measured in a RELEASE
1033    /// build, feeding single-byte chunks (one HTTP chunked frame per byte — both
1034    /// incremental feeders call this once per `hyper` body frame):
1035    ///
1036    /// | retained bytes | cost | vs. 16 KiB |
1037    /// |---|---|---|
1038    /// | 16 KiB | 5.6 ms | 1x |
1039    /// | 64 KiB | 59 ms | 10.6x (4x input) |
1040    /// | 256 KiB | 833 ms | 148x (16x input) |
1041    ///
1042    /// 256 KiB is exactly `MAX_LISTEN_LINE_BYTES`. That was recorded as
1043    /// **D-113-R**, and it is now **CLOSED in plan 113.1-02**: the scan starts at
1044    /// the offset where the new chunk begins instead of at 0. The table above is
1045    /// kept as the record of the defect that motivated the fix.
1046    ///
1047    /// The consequence for THIS test is unchanged and is why it stays: at 4 KiB
1048    /// chunks the per-chunk re-scan is a memchr over at most 1 MiB, single-digit
1049    /// milliseconds in total, so no absolute ceiling here can separate "linear"
1050    /// from "quadratic with a memchr-sized constant". Two negative controls
1051    /// confirm it, and BOTH are recorded rather than erased:
1052    ///
1053    /// - injecting T-113-102's per-chunk full-buffer copy moved this measurement
1054    ///   from 6.7 ms to 11.7 ms and the test still PASSED (113-22-SUMMARY.md);
1055    /// - plan 113.1-02's own post-fix control reverted the scan-window cursor,
1056    ///   and this test PASSED at 5.13 ms in the very run where the two guards
1057    ///   below failed at 4.36 s and 14.85x (113.1-02-SUMMARY.md).
1058    ///
1059    /// # Which of the three `feed` guards owns which shape
1060    ///
1061    /// | test | shape it owns | kind |
1062    /// |---|---|---|
1063    /// | this one | many lines per chunk, 4 KiB x 256 | absolute ceiling |
1064    /// | `sse_parser_feed_1b_chunks_stays_within_its_linear_time_budget` | adversarial 1-byte chunking | absolute ceiling |
1065    /// | `sse_parser_feed_cost_grows_linearly_not_quadratically` | adversarial 1-byte chunking | ratio, machine-independent |
1066    ///
1067    /// So what this test IS: a ceiling that catches a regression making the
1068    /// retained-state path egregiously expensive (a per-chunk re-parse or
1069    /// re-validation) at the many-lines-per-chunk shape — the failure mode fixed
1070    /// in `0493d9fb` — plus a pin on the retention contract: nothing dispatched,
1071    /// nothing overflowed, every byte still held. It is NOT the falsifiable O(n)
1072    /// claim for HTTP-09 clause 3; that lives in the two guards named above, and
1073    /// in `take_utf8_prefix_stays_within_its_linear_time_budget` for the scan that
1074    /// runs BEFORE this one and before every byte-count bound.
1075    #[test]
1076    fn sse_parser_feed_stays_within_its_linear_time_budget() {
1077        const CHUNK_BYTES: usize = 4 * 1024;
1078        const CHUNKS: usize = 256;
1079        // Large enough that the retained total (1 MiB) never trips the bound —
1080        // this test is about cost, not about enforcement.
1081        const BOUND: usize = 4 * 1024 * 1024;
1082        const CEILING: Duration = Duration::from_secs(1);
1083
1084        let chunk = "x".repeat(CHUNK_BYTES);
1085
1086        let best = min_elapsed(3, || {
1087            let mut parser = SseParser::with_max_buffer_size(BOUND);
1088            for _ in 0..CHUNKS {
1089                std::hint::black_box(parser.feed(&chunk));
1090            }
1091            std::hint::black_box(parser.buffered_bytes());
1092        });
1093
1094        eprintln!(
1095            "SseParser::feed: {CHUNKS} x {CHUNK_BYTES} retained bytes in {best:?} \
1096             (ceiling {CEILING:?})"
1097        );
1098
1099        assert!(
1100            best < CEILING,
1101            "SseParser::feed took {best:?} (minimum of 3 runs) to retain {CHUNKS} \
1102             newline-free chunks of {CHUNK_BYTES} bytes; the ceiling is {CEILING:?}. \
1103             The retained-state path costs single-digit milliseconds here, so a \
1104             measurement anywhere near this ceiling means a per-chunk cost over the \
1105             whole retained buffer was introduced. Bound the work per chunk; do not \
1106             raise this number."
1107        );
1108
1109        // The retention contract this path depends on, checked on an untimed run
1110        // so a failure reads as a behaviour change rather than a slow machine.
1111        let mut parser = SseParser::with_max_buffer_size(BOUND);
1112        for _ in 0..CHUNKS {
1113            assert!(
1114                parser.feed(&chunk).is_empty(),
1115                "no chunk carries a newline, so no line completes and nothing dispatches"
1116            );
1117        }
1118        assert!(
1119            !parser.overflowed(),
1120            "{} retained bytes is under the {BOUND}-byte bound",
1121            parser.buffered_bytes()
1122        );
1123        assert_eq!(
1124            parser.buffered_bytes(),
1125            CHUNKS * CHUNK_BYTES,
1126            "every fed byte is still retained — that is what makes this the \
1127             accumulating path"
1128        );
1129    }
1130
1131    /// The LOAD-BEARING absolute budget for `feed` at ADVERSARIAL chunking (HTTP-09 clause 3).
1132    ///
1133    /// `sse_parser_feed_stays_within_its_linear_time_budget` above cannot catch
1134    /// this defect class and says so in its own rustdoc: at its 4 KiB chunking
1135    /// the per-call re-scan is a memchr over at most 1 MiB, single-digit
1136    /// milliseconds, and a negative control (a per-chunk full-buffer copy) moved
1137    /// it 6.7 ms -> 11.7 ms while it still PASSED. The shape that separates
1138    /// linear from quadratic is the one a peer actually gets to choose: **one
1139    /// byte per `feed()` call**, with nothing ever completing, so the retained
1140    /// prefix grows monotonically and every call re-scans it.
1141    ///
1142    /// # Why 512 KiB and why one second
1143    ///
1144    /// Measured at `opt-level = 0` (the profile `cargo test` builds this module
1145    /// with — the crate sets no `[profile.dev]` override), 512 KiB fed one byte
1146    /// per call, minimum of 3 runs, on the plan 113.1-02 development machine:
1147    ///
1148    /// | shape | cost | vs. this ceiling |
1149    /// |---|---|---|
1150    /// | committed scan-window cursor | 63.6 ms | 15.7x UNDER |
1151    /// | pre-113.1-02: `find` restarting at offset 0 every call | 6.81 s | 6.8x OVER |
1152    ///
1153    /// BOTH rows were measured, the second twice: once against the unfixed tree
1154    /// before the fix landed, and again by a post-fix negative control that
1155    /// reverted only the cursor. The margins are asymmetric and written down on
1156    /// purpose — the honest defence of a wall-clock assertion is its measurement.
1157    #[test]
1158    fn sse_parser_feed_1b_chunks_stays_within_its_linear_time_budget() {
1159        const CHUNKS: usize = 512 * 1024;
1160        // Large enough that the retained total never trips the bound — this test
1161        // is about cost, not about enforcement. `DEFAULT_MAX_BUFFER_SIZE` is
1162        // 1 MiB, which would admit 512 KiB, but the bound is stated explicitly so
1163        // the test does not silently change meaning if that default moves.
1164        const BOUND: usize = 4 * 1024 * 1024;
1165        const CEILING: Duration = Duration::from_secs(1);
1166
1167        let best = min_elapsed(3, || {
1168            let mut parser = SseParser::with_max_buffer_size(BOUND);
1169            // Neither `\n` nor `\r`: nothing ever drains, so every call scans a
1170            // buffer one byte longer than the last. That is the adversarial shape.
1171            for _ in 0..CHUNKS {
1172                std::hint::black_box(parser.feed("x"));
1173            }
1174            std::hint::black_box(parser.buffered_bytes());
1175        });
1176
1177        eprintln!("SseParser::feed: {CHUNKS} single-byte chunks in {best:?} (ceiling {CEILING:?})");
1178
1179        assert!(
1180            best < CEILING,
1181            "SseParser::feed took {best:?} (minimum of 3 runs) to absorb {CHUNKS} \
1182             single-byte chunks; the ceiling is {CEILING:?}.\n\
1183             \n\
1184             This excludes ONE shape: restarting the `find('\\n')` search at offset \
1185             0 on every call, so a peer sending N one-byte chunks pays O(N^2) \
1186             scanning. Measured at opt-level 0: that shape costs ~6.81 s here and \
1187             the committed scan-window cursor costs ~63.6 ms, so this ceiling sits \
1188             ~15.7x above linear and ~6.8x below quadratic.\n\
1189             \n\
1190             A measurement in the upper region means the per-call rescan is back, \
1191             not that the machine is slow. Do NOT raise this number to make the \
1192             test pass; that converts the guard back into the unfalsifiable ceiling \
1193             D-113-R exists to record (plan 113.1-02)."
1194        );
1195
1196        // A time budget alone would also be satisfied by a parser that got fast by
1197        // retaining LESS. Pin the retention contract at the budget size too, so
1198        // "fast" has to mean "fast AND still holding every byte".
1199        let mut parser = SseParser::with_max_buffer_size(BOUND);
1200        for _ in 0..CHUNKS {
1201            assert!(
1202                parser.feed("x").is_empty(),
1203                "no chunk carries a newline, so no line completes and nothing dispatches"
1204            );
1205        }
1206        assert_eq!(
1207            parser.buffered_bytes(),
1208            CHUNKS,
1209            "every fed byte is still retained — that is what makes this the \
1210             accumulating path"
1211        );
1212    }
1213
1214    /// The MACHINE-INDEPENDENT guard for `feed` at adversarial chunking (D-16).
1215    ///
1216    /// `take_utf8_prefix` carries both an absolute budget and this ratio; `feed`
1217    /// only ever got the budget half. A 4x input step predicts ~4x time under
1218    /// O(n) and ~16x under O(n^2), so a ceiling of 8x separates them with roughly
1219    /// equal log-scale headroom.
1220    ///
1221    /// This one cannot be flaked by a slow or loaded CI box — it cancels the
1222    /// machine's constant factor — and unlike the absolute budget it also catches
1223    /// a merely-worse-but-still-under-ceiling regression. Measured on the plan
1224    /// 113.1-02 development machine, both sizes fed one byte per call:
1225    ///
1226    /// | shape | 64 KiB | 256 KiB | ratio |
1227    /// |---|---|---|---|
1228    /// | committed scan-window cursor | 6.70 ms | 29.4 ms | **4.39x** |
1229    /// | pre-113.1-02: `find` restarting at 0 | 113 ms | 1.71 s | **15.06x** |
1230    #[test]
1231    fn sse_parser_feed_cost_grows_linearly_not_quadratically() {
1232        const SMALL: usize = 64 * 1024;
1233        const BIG: usize = 4 * SMALL;
1234        // Below this, the measurement is dominated by timer resolution and
1235        // allocator warm-up rather than by the scan.
1236        const RESOLUTION_FLOOR: Duration = Duration::from_micros(50);
1237        const MAX_RATIO: f64 = 8.0;
1238        const BOUND: usize = 4 * 1024 * 1024;
1239
1240        let measure = |chunks: usize| {
1241            min_elapsed(3, || {
1242                let mut parser = SseParser::with_max_buffer_size(BOUND);
1243                for _ in 0..chunks {
1244                    std::hint::black_box(parser.feed("x"));
1245                }
1246                std::hint::black_box(parser.buffered_bytes());
1247            })
1248        };
1249
1250        let small = measure(SMALL);
1251        let big = measure(BIG);
1252
1253        if small < RESOLUTION_FLOOR {
1254            eprintln!(
1255                "sse_parser_feed_cost_grows_linearly_not_quadratically: SKIPPING the \
1256                 ratio assertion. The {SMALL}-chunk measurement is {small:?}, under \
1257                 the {RESOLUTION_FLOOR:?} floor, so the ratio would be asserted on \
1258                 noise. The absolute budget in \
1259                 sse_parser_feed_1b_chunks_stays_within_its_linear_time_budget is \
1260                 unaffected and remains load-bearing, so the guard does not degrade \
1261                 to nothing."
1262            );
1263            return;
1264        }
1265
1266        let ratio = big.as_secs_f64() / small.as_secs_f64();
1267        eprintln!(
1268            "SseParser::feed growth: {SMALL} -> {small:?}, {BIG} -> {big:?}, \
1269             ratio {ratio:.2}x (ceiling {MAX_RATIO:.1}x)"
1270        );
1271        assert!(
1272            ratio <= MAX_RATIO,
1273            "SseParser::feed cost grew {ratio:.1}x for a 4x input step \
1274             ({SMALL} single-byte chunks -> {small:?}, {BIG} -> {big:?}); the \
1275             ceiling is {MAX_RATIO:.1}x.\n\
1276             \n\
1277             O(n) predicts ~4x (measured 4.39x on the committed scan-window cursor: \
1278             6.70 ms -> 29.4 ms). O(n^2) predicts ~16x (measured 15.06x on the \
1279             pre-113.1-02 shape that restarts `find` at offset 0: 113 ms -> \
1280             1.71 s). A ratio in the upper region is the quadratic shape, not \
1281             scheduling noise — this is a minimum over 3 runs at each size, and \
1282             noise raises a minimum only by raising every sample."
1283        );
1284    }
1285
1286    /// The soundness precondition for the scan-window cursor, as a named test.
1287    ///
1288    /// `drain_complete_lines` starts its `find('\n')` search at the byte offset
1289    /// where the NEW chunk begins, skipping the retained prefix. That is only
1290    /// correct because the retained prefix cannot contain a `'\n'`. This test is
1291    /// what pins that invariant.
1292    ///
1293    /// It replaces the per-call `debug_assert!(!self.buffer.contains('\n'))`
1294    /// deleted in plan 113.1-02: that assertion was itself an O(retained-length)
1295    /// scan on EVERY call, so it kept the function quadratic in debug builds —
1296    /// which is exactly the profile `cargo test` and the 1-byte-chunk proptest
1297    /// run in. The invariant is unchanged; only its enforcement moved from a
1298    /// per-call scan to one test covering feeds of every shape.
1299    #[test]
1300    fn drain_leaves_no_newline_in_the_retained_buffer() {
1301        // Each case is a sequence of chunks fed to ONE parser in order.
1302        let cases: &[(&str, &[&str])] = &[
1303            (
1304                "a line split mid-way across two chunks",
1305                &["data: hel", "lo\n\n"],
1306            ),
1307            (
1308                "a CRLF split across a chunk boundary",
1309                &["data: x\r", "\n\r\n"],
1310            ),
1311            ("a bare empty line", &["\n\n"]),
1312            (
1313                "a chunk that is exactly one newline",
1314                &["data: y", "\n", "\n"],
1315            ),
1316            // NOT "a multi-byte character split across two feeds" — that is
1317            // unrepresentable through `feed(&str)`, since every `&str` is valid
1318            // UTF-8 by construction. What IS reachable is a chunk boundary placed
1319            // immediately before and immediately after such a character.
1320            (
1321                "a boundary hugging a multi-byte character",
1322                &["data: ", "\u{2602}", "\u{4f60}\u{597d}\n\n"],
1323            ),
1324            ("a lone CR retained with no LF yet", &["data: z\r"]),
1325            ("nothing but a partial line", &["data: unterminated"]),
1326        ];
1327
1328        for (name, chunks) in cases {
1329            let mut parser = SseParser::new();
1330            for chunk in *chunks {
1331                let _ = parser.feed(chunk);
1332                assert!(
1333                    !parser.buffer.contains('\n'),
1334                    "case {name:?}: the retained buffer holds a newline ({:?}) after \
1335                     feeding {chunk:?}. The scan-window cursor in \
1336                     drain_complete_lines skips this prefix, so a newline hiding in \
1337                     it would silently drop or merge SSE frames.",
1338                    parser.buffer,
1339                );
1340            }
1341        }
1342    }
1343
1344    /// The named, readable companion to the retained-tail property test below.
1345    ///
1346    /// A 4-byte UTF-8 sequence truncated to its first 3 bytes is the WORST case:
1347    /// 3 is the largest incomplete-character prefix that exists, so it is the
1348    /// exact figure `take_utf8_prefix`'s rustdoc promises ("at most 3 bytes").
1349    /// The property test proves the bound over arbitrary input; this states what
1350    /// the number means, so a future reader does not have to derive `4` from a
1351    /// `prop_assert!`.
1352    #[test]
1353    fn take_utf8_prefix_retained_tail_is_documented_bound() {
1354        // U+1F600 GRINNING FACE = F0 9F 98 80, cut one byte short.
1355        let mut buffer = vec![0xF0u8, 0x9F, 0x98];
1356        let text = take_utf8_prefix(&mut buffer);
1357
1358        assert!(text.is_empty(), "nothing is decodable yet");
1359        assert_eq!(
1360            buffer,
1361            vec![0xF0, 0x9F, 0x98],
1362            "all three bytes are retained for the next chunk — the maximum this \
1363             function may ever hold, and the reason the accumulation in \
1364             `connect_sse` and in the listen client is bounded across iterations"
1365        );
1366
1367        // The next chunk completes it and nothing was lost across the split.
1368        buffer.push(0x80);
1369        assert_eq!(take_utf8_prefix(&mut buffer), "\u{1F600}");
1370        assert_eq!(buffer, [] as [u8; 0]);
1371    }
1372
1373    proptest::proptest! {
1374        /// The ≤3-byte retained tail, over ARBITRARY bytes rather than three
1375        /// hand-picked fixtures (HTTP-09, T-113-103).
1376        ///
1377        /// This is the invariant that makes the accumulation in `connect_sse`'s
1378        /// reader and in the `subscriptions/listen` client bounded ACROSS
1379        /// iterations: each call hands back a buffer holding at most an
1380        /// incomplete character, so the undecoded `Vec` cannot grow with stream
1381        /// age no matter how a peer frames its chunks. Until now it rested on
1382        /// the three fixtures in
1383        /// `take_utf8_prefix_decodes_mixed_valid_and_invalid_runs`.
1384        #[test]
1385        fn property_take_utf8_prefix_retains_at_most_a_three_byte_tail(
1386            bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..4096),
1387        ) {
1388            let mut buffer = bytes.clone();
1389            let _text = take_utf8_prefix(&mut buffer);
1390
1391            // (a) the documented bound. Only the HEAD of an over-long residual is
1392            // printed: on failure this can hold thousands of bytes, and a hex dump
1393            // of all of them buries the number that matters.
1394            proptest::prop_assert!(
1395                buffer.len() < 4,
1396                "retained {} bytes (starting {:x?}) — the incomplete-character tail \
1397                 is at most 3, and a larger residual is unbounded accumulation \
1398                 across chunks, not a decode detail",
1399                buffer.len(),
1400                &buffer[..buffer.len().min(8)],
1401            );
1402
1403            // (b) the residual is a SUFFIX of the input. Anything else would mean
1404            // bytes were reordered or resurrected from the middle of the buffer,
1405            // and the caller appends the NEXT chunk directly onto this residual.
1406            proptest::prop_assert!(
1407                bytes.ends_with(&buffer),
1408                "residual {:x?} is not a suffix of the input",
1409                buffer,
1410            );
1411
1412            // (c) no wedge. Feeding the residual plus one more ASCII byte always
1413            // terminates AND always clears: the retained tail is by construction
1414            // an incomplete-character PREFIX at the end of the buffer, and a
1415            // buffer ending in a complete 1-byte character has no such tail. This
1416            // is what stops hostile input from parking bytes in the decoder
1417            // forever (T-113-67).
1418            buffer.push(b'!');
1419            let follow_up = take_utf8_prefix(&mut buffer);
1420            proptest::prop_assert!(
1421                buffer.len() < 4,
1422                "the second call retained {} bytes",
1423                buffer.len(),
1424            );
1425            proptest::prop_assert!(
1426                buffer.is_empty(),
1427                "an ASCII byte completes the buffer, so nothing may be retained; \
1428                 residual {:x?} after decoding {:?}",
1429                buffer,
1430                follow_up,
1431            );
1432        }
1433    }
1434
1435    #[test]
1436    fn test_sse_parser_simple() {
1437        let mut parser = SseParser::new();
1438
1439        let input = "data: hello world\n\n";
1440        let events = parser.feed(input);
1441
1442        assert_eq!(events.len(), 1);
1443        assert_eq!(events[0].data, "hello world");
1444        assert_eq!(events[0].event, None);
1445        assert_eq!(events[0].id, None);
1446    }
1447
1448    #[test]
1449    fn test_sse_parser_with_event_type() {
1450        let mut parser = SseParser::new();
1451
1452        let input = "event: message\ndata: hello\n\n";
1453        let events = parser.feed(input);
1454
1455        assert_eq!(events.len(), 1);
1456        assert_eq!(events[0].data, "hello");
1457        assert_eq!(events[0].event, Some("message".to_string()));
1458    }
1459
1460    #[test]
1461    fn test_sse_parser_multiline_data() {
1462        let mut parser = SseParser::new();
1463
1464        let input = "data: line 1\ndata: line 2\ndata: line 3\n\n";
1465        let events = parser.feed(input);
1466
1467        assert_eq!(events.len(), 1);
1468        assert_eq!(events[0].data, "line 1\nline 2\nline 3");
1469    }
1470
1471    #[test]
1472    fn test_sse_parser_with_id() {
1473        let mut parser = SseParser::new();
1474
1475        let input = "id: 123\ndata: test\n\n";
1476        let events = parser.feed(input);
1477
1478        assert_eq!(events.len(), 1);
1479        assert_eq!(events[0].id, Some("123".to_string()));
1480        assert_eq!(parser.last_event_id(), Some("123"));
1481    }
1482
1483    #[test]
1484    fn test_sse_parser_with_retry() {
1485        let mut parser = SseParser::new();
1486
1487        let input = "retry: 5000\ndata: test\n\n";
1488        let events = parser.feed(input);
1489
1490        assert_eq!(events.len(), 1);
1491        assert_eq!(events[0].retry, Some(5000));
1492    }
1493
1494    #[test]
1495    fn test_sse_parser_comments() {
1496        let mut parser = SseParser::new();
1497
1498        let input = ": this is a comment\ndata: actual data\n\n";
1499        let events = parser.feed(input);
1500
1501        assert_eq!(events.len(), 1);
1502        assert_eq!(events[0].data, "actual data");
1503    }
1504
1505    #[test]
1506    fn test_sse_parser_incremental() {
1507        let mut parser = SseParser::new();
1508
1509        // Feed data incrementally
1510        let events1 = parser.feed("data: par");
1511        assert_eq!(events1.len(), 0);
1512
1513        let events2 = parser.feed("tial\ndata: more");
1514        assert_eq!(events2.len(), 0);
1515
1516        let events3 = parser.feed("\n\n");
1517        assert_eq!(events3.len(), 1);
1518        assert_eq!(events3[0].data, "partial\nmore");
1519    }
1520
1521    /// A remote peer that never emits a newline must not be able to grow a
1522    /// pmcp client's heap without limit.
1523    ///
1524    /// `feed` pushes every chunk into `buffer` and only ever drains as far as a
1525    /// `\n`, so before the bound existed a hostile or broken server could hold a
1526    /// `subscriptions/listen` stream open and stream newline-free bytes until
1527    /// the client ran out of memory (review CR-03, verification gap item 3,
1528    /// T-113-73).
1529    #[test]
1530    fn a_newlineless_flood_cannot_grow_the_buffer_past_the_bound() {
1531        let bound = SseConfig::default().max_buffer_size;
1532        let mut parser = SseParser::new();
1533        let chunk = "x".repeat(64 * 1024);
1534        // 2 MiB, with not one newline in it.
1535        for _ in 0..32 {
1536            assert!(
1537                parser.feed(&chunk).is_empty(),
1538                "an unterminated line completes no event"
1539            );
1540        }
1541        assert!(
1542            parser.buffered_bytes() <= bound,
1543            "the parser retained {} bytes, past the {bound}-byte bound",
1544            parser.buffered_bytes()
1545        );
1546    }
1547
1548    /// The TWIN of the test above, and the one that matters: a peer sending
1549    /// perfectly ordinary NEWLINE-TERMINATED `data:` lines, forever, without ever
1550    /// sending the blank line that would dispatch the event.
1551    ///
1552    /// Every chunk here is well-formed, so the pre-113-17 bound — which only
1553    /// refused chunks carrying no newline at all — never fired: 100 000 chunks
1554    /// under a 64-byte bound accumulated 899 999 bytes into `current_event.data`
1555    /// with `overflowed()` still false (independently reproduced by the phase
1556    /// verifier; review CR-01, T-113-79). The bound must cover what accumulates
1557    /// ACROSS lines, not only the unterminated line.
1558    #[test]
1559    fn a_newline_carrying_flood_cannot_grow_the_event_past_the_bound() {
1560        let mut parser = SseParser::with_max_buffer_size(64);
1561        for _ in 0..100_000 {
1562            let _ = parser.feed("data: AAAAAAAA\n");
1563        }
1564        assert!(
1565            parser.overflowed(),
1566            "100,000 newline-terminated `data:` lines accumulated {} bytes \
1567             without tripping the 64-byte bound",
1568            parser.buffered_bytes()
1569        );
1570        assert!(
1571            parser.buffered_bytes() <= 64,
1572            "the parser retains {} bytes, past its 64-byte bound",
1573            parser.buffered_bytes()
1574        );
1575    }
1576
1577    /// A single COMPLETE line larger than the bound is REFUSED, not parsed and
1578    /// emitted.
1579    ///
1580    /// Before 113-17 the `contains('\n')` escape let this through whole: the
1581    /// parser returned one 1 000 000-byte event from a 64-byte-bounded parser
1582    /// with `overflowed()` false, so the flag could not observe the condition it
1583    /// exists to detect (review CR-02, T-113-80).
1584    #[test]
1585    fn an_oversized_complete_line_is_refused_not_emitted() {
1586        let mut parser = SseParser::with_max_buffer_size(64);
1587        let body = format!("data: {}\n\n", "B".repeat(1_000_000));
1588
1589        let events = parser.feed(&body);
1590
1591        assert!(
1592            events.is_empty(),
1593            "a 1,000,000-byte line under a 64-byte bound must be refused; got \
1594             {} event(s), the first of {} bytes",
1595            events.len(),
1596            events.first().map_or(0, |event| event.data.len())
1597        );
1598        assert!(parser.overflowed(), "and the refusal is observable");
1599    }
1600
1601    /// The default bound is the one `SseConfig` already documented — the number
1602    /// is sourced from there, not re-typed, so there is exactly ONE of it.
1603    #[test]
1604    fn new_takes_its_bound_from_the_sse_config_default() {
1605        let parser = SseParser::new();
1606        assert_eq!(parser.max_buffer_size, SseConfig::default().max_buffer_size);
1607        assert_eq!(parser.max_buffer_size, 1024 * 1024, "1 MiB");
1608        assert!(!parser.overflowed(), "a fresh parser has lost nothing");
1609    }
1610
1611    /// The limit is real CONFIG, not a constant baked into `feed`: a parser
1612    /// built with a tighter bound trips on bytes the default-bounded one
1613    /// swallows without complaint.
1614    #[test]
1615    fn with_max_buffer_size_bounds_at_the_value_given() {
1616        let flood = "x".repeat(256);
1617
1618        let mut tight = SseParser::with_max_buffer_size(64);
1619        assert_eq!(
1620            tight.feed(&flood),
1621            [] as [crate::shared::sse_parser::SseEvent; 0]
1622        );
1623        assert!(tight.overflowed(), "256 bytes is past a 64-byte bound");
1624        assert!(tight.buffer.is_empty(), "the oversized line was discarded");
1625
1626        let mut wide = SseParser::new();
1627        let _ = wide.feed(&flood);
1628        assert!(
1629            !wide.overflowed(),
1630            "the same bytes are nowhere near the 1 MiB default"
1631        );
1632    }
1633
1634    /// The flag never auto-clears, so a caller polling once per chunk cannot
1635    /// miss the event even when well-formed frames follow the bad one.
1636    #[test]
1637    fn the_overflow_flag_latches() {
1638        let mut parser = SseParser::with_max_buffer_size(64);
1639        assert_eq!(
1640            parser.feed(&"x".repeat(256)),
1641            [] as [crate::shared::sse_parser::SseEvent; 0]
1642        );
1643        assert!(parser.overflowed());
1644
1645        let events = parser.feed("data: ok\n\n");
1646        assert_eq!(events.len(), 1, "the parser keeps working");
1647        assert_eq!(events[0].data, "ok");
1648        assert!(parser.overflowed(), "and the flag stays set");
1649
1650        parser.reset();
1651        assert!(
1652            parser.overflowed(),
1653            "reset cannot un-lose the discarded bytes"
1654        );
1655    }
1656
1657    /// Events completed BEFORE the oversized line are still delivered, and the
1658    /// overflowing feed itself completes nothing rather than emitting a
1659    /// truncated frame that would fail JSON parsing with a misleading error.
1660    #[test]
1661    fn events_completed_before_an_oversized_line_are_still_returned() {
1662        let mut parser = SseParser::with_max_buffer_size(64);
1663
1664        let events = parser.feed("data: first\n\n");
1665        assert_eq!(events.len(), 1);
1666        assert_eq!(events[0].data, "first");
1667        assert!(!parser.overflowed());
1668
1669        let events = parser.feed(&"x".repeat(256));
1670        assert!(events.is_empty(), "an overflowing feed completes nothing");
1671        assert!(parser.overflowed());
1672    }
1673
1674    /// A chunk that CONTAINS a newline skips the pre-check, so the bound has to
1675    /// hold on the RESIDUAL the drain loop leaves behind. Without the residual
1676    /// check a single `"\n" + 256 bytes` chunk parks 256 bytes in a 64-byte
1677    /// parser — and a peer can repeat that with megabyte chunks.
1678    #[test]
1679    fn a_newline_prefixed_flood_still_trips_the_bound() {
1680        let mut parser = SseParser::with_max_buffer_size(64);
1681
1682        let mut chunk = String::from("\n");
1683        chunk.push_str(&"x".repeat(256));
1684        let events = parser.feed(&chunk);
1685
1686        assert!(
1687            events.is_empty(),
1688            "the leading blank line dispatches nothing"
1689        );
1690        assert!(
1691            parser.overflowed(),
1692            "the residual unterminated line exceeds the bound and is discarded"
1693        );
1694
1695        // The parser keeps working afterwards, exactly as after a pre-check trip.
1696        let events = parser.feed("data: ok\n\n");
1697        assert_eq!(events.len(), 1);
1698        assert_eq!(events[0].data, "ok");
1699    }
1700
1701    /// A parser that never exceeds its bound behaves byte-identically to the
1702    /// pre-bound parser — every other test in this module is the rest of that
1703    /// proof; this one pins the flag itself.
1704    #[test]
1705    fn a_parser_under_its_bound_never_reports_overflow() {
1706        let mut parser = SseParser::new();
1707        let events = parser.feed("id: 7\nevent: message\ndata: {\"a\":1}\n\n");
1708        assert_eq!(events.len(), 1);
1709        assert_eq!(events[0].data, "{\"a\":1}");
1710        assert_eq!(parser.last_event_id(), Some("7"));
1711        assert!(!parser.overflowed());
1712    }
1713
1714    #[test]
1715    fn test_sse_stream_builder() {
1716        let stream = SseStream::new()
1717            .data("simple message")
1718            .typed_event("ping", "pong")
1719            .event(SseEvent::new("complex").with_id("42").with_retry(1000))
1720            .build();
1721
1722        assert!(stream.contains("data: simple message"));
1723        assert!(stream.contains("event: ping"));
1724        assert!(stream.contains("data: pong"));
1725        assert!(stream.contains("id: 42"));
1726        assert!(stream.contains("retry: 1000"));
1727    }
1728
1729    /// Regression: a multi-byte character before the first `\n`, with a `\r`
1730    /// later in the buffer, used to PANIC with "byte index N is not a char
1731    /// boundary".
1732    ///
1733    /// The old CRLF check indexed by CHARACTER (`chars().nth(line_end - 1)`)
1734    /// while `line_end` is a BYTE index, so it reported `'\r'` for a position
1735    /// that was actually inside `'\u{2602}'`, and the slice that followed cut
1736    /// mid-character. These bytes come off the wire from a remote server, so
1737    /// the panic was a remote-triggerable client crash (T-113-67).
1738    #[test]
1739    fn feed_does_not_panic_on_a_multibyte_char_before_a_later_cr() {
1740        let mut parser = SseParser::new();
1741        // bytes: 0..2 = '\u{2602}', 3 = '\n', 4 = '\r', 5 = 'X', 6 = '\n'.
1742        // `find('\n')` is 3 and `chars().nth(2)` was `'\r'` — the disagreement.
1743        let events = parser.feed("\u{2602}\n\rX\n");
1744        assert!(
1745            events.is_empty(),
1746            "neither line carries data, so nothing dispatches: {events:?}"
1747        );
1748    }
1749
1750    /// A CRLF-terminated line still has its `\r` stripped, and a multi-byte
1751    /// payload survives intact.
1752    #[test]
1753    fn feed_strips_crlf_and_preserves_multibyte_data() {
1754        let mut parser = SseParser::new();
1755        let events = parser.feed("data: \u{2602}-\u{4f60}\u{597d}\r\n\r\n");
1756        assert_eq!(events.len(), 1);
1757        assert_eq!(events[0].data, "\u{2602}-\u{4f60}\u{597d}");
1758    }
1759
1760    proptest::proptest! {
1761        /// `feed` runs on bytes a remote peer chose. It must never panic, for
1762        /// ANY text, at ANY chunk split.
1763        #[test]
1764        fn feed_never_panics_on_arbitrary_text(
1765            chunks in proptest::collection::vec(
1766                "(\\PC|\r|\n|\u{2602}|\u{4f60}){0,40}",
1767                0..4,
1768            ),
1769        ) {
1770            let mut parser = SseParser::new();
1771            for chunk in chunks {
1772                let _ = parser.feed(&chunk);
1773            }
1774        }
1775
1776        /// The SAME text, re-cut at different CHARACTER boundaries, must produce
1777        /// the same events and leave the same retained tail.
1778        ///
1779        /// This is the property the scan-window cursor (D-12) can violate: it
1780        /// starts each search at the offset where the new chunk begins, so a bug
1781        /// in where that offset lands would drop or merge frames only for SOME
1782        /// chunkings — invisible to any fixed-fixture test. The claim is
1783        /// deliberately *arbitrary text under arbitrary character-boundary
1784        /// chunking*, and NOT a claim over arbitrary raw bytes: `feed` takes
1785        /// `&str`, and every `&str` is valid UTF-8 by construction, so a `feed`
1786        /// call can never receive half a character. Byte-level fragmentation is
1787        /// [`take_utf8_prefix`]'s responsibility and has its own coverage there
1788        /// (`property_take_utf8_prefix_retains_at_most_a_three_byte_tail`), so
1789        /// this property has no hole where that case would be.
1790        ///
1791        /// The strategy is the block's established alphabet, which already mixes
1792        /// `\r`, `\n` and multi-byte characters — exactly what this needs.
1793        #[test]
1794        fn property_feed_chunking_invariance(
1795            chunks_a in proptest::collection::vec(
1796                "(\\PC|\r|\n|\u{2602}|\u{4f60}){0,40}",
1797                0..4,
1798            ),
1799            split_sizes in proptest::collection::vec(1usize..7usize, 1..8),
1800        ) {
1801            // Splitting A is the generated vector itself, so the reference text is
1802            // its concatenation — which makes character-boundary safety structural
1803            // rather than something the test has to get right.
1804            let text: String = chunks_a.concat();
1805
1806            // Splitting B re-cuts that same text at independent character counts,
1807            // never at raw byte offsets (slicing `&str` at a non-boundary index
1808            // panics).
1809            let chars: Vec<char> = text.chars().collect();
1810            let mut chunks_b: Vec<String> = Vec::new();
1811            let mut at = 0usize;
1812            // `split_sizes` is generated non-empty, so the cycle never ends.
1813            let mut sizes = split_sizes.iter().copied().cycle();
1814            while at < chars.len() {
1815                let end = (at + sizes.next().unwrap_or(1)).min(chars.len());
1816                chunks_b.push(chars[at..end].iter().collect());
1817                at = end;
1818            }
1819
1820            // The newline-free retained buffer is the SOUNDNESS PRECONDITION for
1821            // the scan-window cursor (D-15): `scan_start` skips the retained
1822            // prefix, which is only correct if no '\n' can hide there. The
1823            // per-call `debug_assert` that used to enforce it universally was
1824            // deleted because it was itself an O(retained) scan. Asserting it
1825            // here restores generative coverage — this test already drives
1826            // arbitrary text under arbitrary chunkings, which is exactly the
1827            // generator that finds shapes the seven fixtures in
1828            // `drain_leaves_no_newline_in_the_retained_buffer` do not.
1829            let mut parser_a = SseParser::new();
1830            let mut events_a = Vec::new();
1831            for chunk in &chunks_a {
1832                events_a.extend(parser_a.feed(chunk));
1833                proptest::prop_assert!(
1834                    !parser_a.buffer.contains('\n'),
1835                    "retained buffer holds a newline after feeding {:?}: {:?}",
1836                    chunk,
1837                    parser_a.buffer
1838                );
1839            }
1840
1841            let mut parser_b = SseParser::new();
1842            let mut events_b = Vec::new();
1843            for chunk in &chunks_b {
1844                events_b.extend(parser_b.feed(chunk));
1845                proptest::prop_assert!(
1846                    !parser_b.buffer.contains('\n'),
1847                    "retained buffer holds a newline after feeding {:?}: {:?}",
1848                    chunk,
1849                    parser_b.buffer
1850                );
1851            }
1852
1853            proptest::prop_assert_eq!(
1854                &events_a,
1855                &events_b,
1856                "the same text emitted a different event sequence under a \
1857                 different chunking (A: {:?}, B: {:?})",
1858                chunks_a,
1859                chunks_b
1860            );
1861            proptest::prop_assert_eq!(
1862                &parser_a.buffer,
1863                &parser_b.buffer,
1864                "the same text left a different retained tail under a different \
1865                 chunking (A: {:?}, B: {:?})",
1866                chunks_a,
1867                chunks_b
1868            );
1869        }
1870
1871        /// And neither does a TIGHTLY bounded parser, whose enforcement branch
1872        /// discards a partial line mid-stream — including one cut in the middle
1873        /// of a multi-byte character (the 113-13 char-boundary guard, now
1874        /// exercised against the bound as well).
1875        #[test]
1876        fn a_bounded_feed_never_panics_on_arbitrary_text(
1877            chunks in proptest::collection::vec(
1878                "(\\PC|\r|\n|\u{2602}|\u{4f60}){0,40}",
1879                0..4,
1880            ),
1881        ) {
1882            let mut parser = SseParser::with_max_buffer_size(8);
1883            for chunk in chunks {
1884                let _ = parser.feed(&chunk);
1885                // The bound holds on RETURN, over BOTH accumulators, with no
1886                // slack for "the tail of this chunk". The looser form this
1887                // replaced (`buffer.len() <= max(8, chunk.len())`) is exactly
1888                // why the property test could not see GAP-A: it excused any
1889                // residual up to the size of the chunk, and it ignored the
1890                // event accumulator entirely (review CR-02).
1891                proptest::prop_assert!(
1892                    parser.buffered_bytes() <= parser.max_buffer_size(),
1893                    "the parser retains {} bytes, past its {}-byte bound",
1894                    parser.buffered_bytes(),
1895                    parser.max_buffer_size(),
1896                );
1897            }
1898        }
1899    }
1900
1901    #[test]
1902    fn test_sse_event_display() {
1903        let event = SseEvent::new("test data")
1904            .with_id("123")
1905            .with_event("message")
1906            .with_retry(3000);
1907
1908        let output = event.to_string();
1909        assert!(output.contains("id: 123"));
1910        assert!(output.contains("event: message"));
1911        assert!(output.contains("retry: 3000"));
1912        assert!(output.contains("data: test data"));
1913    }
1914}