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}