sse-parse 0.1.0

Incremental, linear-time parser for Server-Sent Events byte streams, with a sans-IO core and a runtime-agnostic Stream adapter
Documentation
//! BOM gate, the D2 regression: the byte order mark is stripped exactly
//! once, at stream offset 0, chunk-boundary-proof, and never anywhere else.

mod common;

use common::{events_of, load_vectors, run_parser, run_parser_whole, split_by_sizes};
use proptest::prelude::*;

const BOM: &[u8] = &[0xEF, 0xBB, 0xBF];

#[test]
fn d2_bom_vectors_pass() {
    for v in load_vectors() {
        if !v.id.starts_with("v19")
            && !v.id.starts_with("v20")
            && !v.id.starts_with("v21")
            && !v.id.starts_with("v22")
            && !v.id.starts_with("v23")
        {
            continue;
        }
        let got = run_parser(&v.chunks);
        assert_eq!(got.events, v.events, "{}", v.id);
        assert_eq!(got.last_event_id, v.final_last_event_id, "{}", v.id);
    }
}

#[test]
fn d2_bom_stripped_and_no_panic_with_each_chunking() {
    let mut bytes = BOM.to_vec();
    bytes.extend_from_slice(b"data: hi\n\n");
    let expect = events_of(b"data: hi\n\n");
    for sizes in [&[1usize][..], &[2], &[3], &[4], &[2, 1], &[1, 2]] {
        let got = run_parser(&split_by_sizes(&bytes, sizes));
        assert_eq!(got.events, expect, "sizes {sizes:?}");
    }
}

#[test]
fn d2_partial_bom_prefix_is_content() {
    // EF then a non-BOM byte: both bytes are content (an unknown field name).
    let mut bytes = vec![0xEF];
    bytes.extend_from_slice(b"data: x\n\ndata: y\n\n");
    let got = run_parser_whole(&bytes);
    assert_eq!(got.events.len(), 1);
    assert_eq!(got.events[0].data, "y");
    // Same with the prefix split across chunks.
    let got = run_parser(&split_by_sizes(&bytes, &[1]));
    assert_eq!(got.events.len(), 1);
    assert_eq!(got.events[0].data, "y");
}

#[test]
fn d2_partial_bom_then_eof_is_harmless() {
    for prefix in [&BOM[..1], &BOM[..2], BOM] {
        let got = run_parser_whole(prefix);
        assert!(got.events.is_empty());
        assert_eq!(got.last_event_id, "");
    }
}

/// A block body line. No `id` or `retry` fields, so blocks carry no state
/// into one another and "events outside the block" is well defined.
fn body_line() -> impl Strategy<Value = Vec<u8>> {
    let word = "[a-z\u{e9}\u{feff}]{0,6}";
    prop_oneof![
        4 => word.prop_map(|w| format!("data: {w}").into_bytes()),
        2 => word.prop_map(|w| format!("data:{w}").into_bytes()),
        1 => Just(b"data".to_vec()),
        2 => word.prop_map(|w| format!("event: {w}").into_bytes()),
        1 => word.prop_map(|w| format!(": {w}").into_bytes()),
        1 => word.prop_map(|w| w.into_bytes()),
    ]
}

fn terminator() -> impl Strategy<Value = Vec<u8>> {
    prop_oneof![
        4 => Just(b"\n".to_vec()),
        2 => Just(b"\r\n".to_vec()),
        1 => Just(b"\r".to_vec()),
    ]
}

/// A block as (body bytes, blank-line terminator bytes).
///
/// A body ending in a lone CR must not be followed by a bare LF blank line:
/// the two bytes would form one CRLF terminator and the blank line would
/// vanish, merging this block into the next one.
fn block() -> impl Strategy<Value = (Vec<u8>, Vec<u8>)> {
    (
        prop::collection::vec((body_line(), terminator()), 1..4),
        terminator(),
    )
        .prop_map(|(lines, mut blank)| {
            let mut body = Vec::new();
            for (line, term) in lines {
                body.extend_from_slice(&line);
                body.extend_from_slice(&term);
            }
            if body.ends_with(b"\r") && blank == b"\n" {
                blank = b"\r\n".to_vec();
            }
            (body, blank)
        })
}

fn concat_blocks(blocks: &[(Vec<u8>, Vec<u8>)]) -> Vec<u8> {
    let mut out = Vec::new();
    for (body, blank) in blocks {
        out.extend_from_slice(body);
        out.extend_from_slice(blank);
    }
    out
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(1500))]

    #[test]
    fn prepending_a_bom_changes_nothing(
        bytes in prop::collection::vec(any::<u8>(), 0..200),
        sizes in prop::collection::vec(1usize..16, 1..16),
    ) {
        prop_assume!(!bytes.starts_with(BOM));
        let plain = run_parser_whole(&bytes);
        let mut with_bom = BOM.to_vec();
        with_bom.extend_from_slice(&bytes);
        prop_assert_eq!(run_parser_whole(&with_bom), plain.clone());
        prop_assert_eq!(run_parser(&split_by_sizes(&with_bom, &sizes)), plain);
    }

    #[test]
    fn injecting_a_bom_inside_a_block_leaves_other_blocks_unchanged(
        blocks in prop::collection::vec(block(), 1..5),
        pick in any::<prop::sample::Index>(),
        offset in any::<prop::sample::Index>(),
    ) {
        let k = pick.index(blocks.len());
        let body_len = blocks[k].0.len();
        // Inject inside the body, before its final terminator. Bytes between
        // that terminator and the blank line would turn the blank line into
        // a content line and merge the block with the next one.
        let p = offset.index(body_len);
        // Position 0 of block 0 is the stream start, where a BOM is stripped.
        prop_assume!(k > 0 || p > 0);

        let before = events_of(&concat_blocks(&blocks[..k]));
        let after = events_of(&concat_blocks(&blocks[k + 1..]));

        let mut injected = blocks.clone();
        let body = &mut injected[k].0;
        let tail = body.split_off(p);
        body.extend_from_slice(BOM);
        body.extend_from_slice(&tail);
        let got = events_of(&concat_blocks(&injected));

        prop_assert!(got.len() >= before.len() + after.len());
        prop_assert_eq!(&got[..before.len()], &before[..]);
        prop_assert_eq!(&got[got.len() - after.len()..], &after[..]);
    }
}