simoom-http1 0.1.0

Sans-io, strict HTTP/1.1 parser and serializer (RFC 9112) for simoom.
Documentation
//! Parse cost per request head: simoom-http1 with SIMD and with scalar scanning, against the
//! cameleer-http parsing logic (copied below, unchanged in substance, so there is no
//! dependency on it).
//!
//! ```text
//! cargo run --release -p simoom-http1 --example bench_parse
//! ```

use simoom_http1::write::{DateCache, HeadWriter, StaticHead};
use simoom_http1::*;
use simoom_sys::scan::{force, level, Level};
use std::hint::black_box;
use std::time::Instant;

const CURL: &[u8] = b"GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n\r\n";

const BROWSER: &[u8] = b"GET /index.html?q=1 HTTP/1.1\r\nHost: www.example.com\r\nConnection: keep-alive\r\nsec-ch-ua: \"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"\r\nsec-ch-ua-mobile: ?0\r\nsec-ch-ua-platform: \"Windows\"\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r\nSec-Fetch-Site: none\r\nSec-Fetch-Mode: navigate\r\nSec-Fetch-User: ?1\r\nSec-Fetch-Dest: document\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept-Language: en-US,en;q=0.9\r\nCookie: session=8f2a9c1d4e5b6a7f8091a2b3c4d5e6f7; theme=dark; _ga=GA1.1.123456789.1700000000\r\n\r\n";

fn twenty_headers() -> Vec<u8> {
    let mut s = b"POST /api/v1/orders HTTP/1.1\r\nHost: api.internal:8443\r\nContent-Type: application/json\r\nContent-Length: 128\r\n".to_vec();
    for i in 0..17 {
        s.extend_from_slice(format!("X-Custom-Header-{i}: value-{i}-abcdefghijklmnop\r\n").as_bytes());
    }
    s.extend_from_slice(b"\r\n");
    s
}

/// Best of several rounds, in nanoseconds per call.
fn measure(mut f: impl FnMut()) -> f64 {
    let mut iters = 1_000u64;
    loop {
        let t = Instant::now();
        for _ in 0..iters {
            f();
        }
        if t.elapsed().as_millis() >= 50 {
            break;
        }
        iters *= 4;
    }
    (0..7)
        .map(|_| {
            let t = Instant::now();
            for _ in 0..iters {
                f();
            }
            t.elapsed().as_nanos() as f64 / iters as f64
        })
        .fold(f64::INFINITY, f64::min)
}

fn simoom(req: &[u8]) -> usize {
    let mut hs = [EMPTY_HEADER; 32];
    match parse_request(black_box(req), &mut hs, &Limits::default()) {
        Ok(Status::Complete(r)) => r.head_len + r.headers.len(),
        other => panic!("{other:?}"),
    }
}

fn main() {
    let twenty = twenty_headers();
    let cases: [(&str, &[u8]); 3] = [("curl (3 fields)", CURL), ("browser (15 fields)", BROWSER), ("20 fields", &twenty)];
    let best = level();
    println!("scan level detected: {best:?}");
    println!("{:<22} {:>6} {:>12} {:>12} {:>14} {:>9}", "request", "bytes", "simd ns", "scalar ns", "cameleer ns", "GB/s simd");
    for (name, req) in cases {
        assert!(cameleer::parse(req).is_ok());
        force(best);
        let simd = measure(|| {
            black_box(simoom(req));
        });
        force(Level::Scalar);
        let scalar = measure(|| {
            black_box(simoom(req));
        });
        force(best);
        let cam = measure(|| {
            black_box(cameleer::parse(black_box(req)).ok());
        });
        println!(
            "{name:<22} {:>6} {simd:>12.1} {scalar:>12.1} {cam:>14.1} {:>9.2}",
            req.len(),
            req.len() as f64 / simd
        );
    }
    // Serialization.
    let date = DateCache::new(1_700_000_000);
    let mut out = Vec::with_capacity(256);
    let head = measure(|| {
        out.clear();
        let mut w = HeadWriter::response(&mut out, 200);
        w.header(b"Content-Type", b"application/json").unwrap().date(&date).content_length(1234);
        w.finish();
        black_box(&out);
    });
    let mut fixed = StaticHead::new(200, &[(b"Content-Type", b"text/plain"), (b"Server", b"simoom")], b"Hello, World!").unwrap();
    let mut secs = 1_700_000_000;
    let mut dc = DateCache::new(secs);
    let stat = measure(|| {
        black_box(fixed.full(&dc).len());
    });
    let stat_tick = measure(|| {
        secs += 1;
        dc.update(secs);
        black_box(fixed.full(&dc).len());
    });
    println!("HeadWriter (status, 1 field, Date, Content-Length): {head:.1} ns");
    println!("StaticHead::full (fixed reply, same second): {stat:.1} ns; with a new second every call: {stat_tick:.1} ns");
}

/// The request-head parsing path of `cameleer-http` (`crates/cameleer-http/src/http.rs`:
/// `read_line`, `read_head`, `body_kind`, the request-line split, Host check and target
/// handling), copied here so the comparison has no dependency. It reads through `BufRead` and
/// builds owned `String`s, as the original does.
mod cameleer {
    // Copied as is: some fields are built but not read here, exactly as in the original.
    #![allow(dead_code)]

    use std::io::BufRead;

    #[derive(Debug)]
    pub enum HttpError {
        BadRequest(String),
        HeaderTooLarge,
        UnexpectedEof,
        NotImplemented(String),
        PayloadTooLarge,
        Io,
    }

    #[derive(Default)]
    pub struct HttpHeaders {
        entries: Vec<(String, String)>,
    }

    impl HttpHeaders {
        fn get_all(&self, name: &str) -> Vec<&str> {
            self.entries.iter().filter(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).collect()
        }
        fn contains(&self, name: &str) -> bool {
            self.entries.iter().any(|(k, _)| k.eq_ignore_ascii_case(name))
        }
    }

    pub struct HttpRequest {
        pub method: String,
        pub target: String,
        pub headers: HttpHeaders,
    }

    #[derive(Debug)]
    pub enum BodyKind {
        None,
        Length(usize),
        Chunked,
    }

    fn read_line<R: BufRead>(r: &mut R, budget: &mut usize) -> Result<Option<String>, HttpError> {
        let mut buf = Vec::new();
        loop {
            let available = r.fill_buf().map_err(|_| HttpError::Io)?;
            if available.is_empty() {
                if buf.is_empty() {
                    return Ok(None);
                }
                return Err(HttpError::UnexpectedEof);
            }
            let (consume, done) = match available.iter().position(|&b| b == b'\n') {
                Some(i) => (i + 1, true),
                None => (available.len(), false),
            };
            if consume > *budget {
                return Err(HttpError::HeaderTooLarge);
            }
            *budget -= consume;
            buf.extend_from_slice(&available[..consume]);
            r.consume(consume);
            if done {
                break;
            }
        }
        buf.pop();
        if buf.last() == Some(&b'\r') {
            buf.pop();
        }
        String::from_utf8(buf).map(Some).map_err(|_| HttpError::BadRequest("invalid UTF-8 in header".into()))
    }

    fn is_token(s: &str) -> bool {
        !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
    }

    fn read_head<R: BufRead>(r: &mut R) -> Result<Option<(String, HttpHeaders)>, HttpError> {
        let mut budget = 16 * 1024;
        let start = loop {
            match read_line(r, &mut budget)? {
                None => return Ok(None),
                Some(l) if l.is_empty() => continue,
                Some(l) => break l,
            }
        };
        let mut headers = HttpHeaders::default();
        loop {
            let line = read_line(r, &mut budget)?.ok_or(HttpError::UnexpectedEof)?;
            if line.is_empty() {
                break;
            }
            if line.starts_with(' ') || line.starts_with('\t') {
                return Err(HttpError::BadRequest("obsolete header line folding".into()));
            }
            let colon = line.find(':').ok_or_else(|| HttpError::BadRequest(format!("malformed header: {}", line)))?;
            let name = &line[..colon];
            if !is_token(name) {
                return Err(HttpError::BadRequest(format!("invalid header name: {}", name)));
            }
            if headers.entries.len() >= 100 {
                return Err(HttpError::HeaderTooLarge);
            }
            headers.entries.push((name.to_string(), line[colon + 1..].trim().to_string()));
        }
        Ok(Some((start, headers)))
    }

    fn body_kind(headers: &HttpHeaders) -> Result<BodyKind, HttpError> {
        if let Some(te) = headers.get_all("Transfer-Encoding").last().copied() {
            let last = te.split(',').next_back().map(|s| s.trim().to_ascii_lowercase()).unwrap_or_default();
            if last == "chunked" {
                return Ok(BodyKind::Chunked);
            }
            return Err(HttpError::NotImplemented(format!("Transfer-Encoding: {}", te)));
        }
        let lengths = headers.get_all("Content-Length");
        if !lengths.is_empty() {
            let mut value: Option<usize> = None;
            for l in lengths {
                for part in l.split(',') {
                    let part = part.trim();
                    if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
                        return Err(HttpError::BadRequest(format!("invalid Content-Length: {}", l)));
                    }
                    let n: usize = part.parse().map_err(|_| HttpError::PayloadTooLarge)?;
                    if value.is_some_and(|v| v != n) {
                        return Err(HttpError::BadRequest("conflicting Content-Length values".into()));
                    }
                    value = Some(n);
                }
            }
            return Ok(BodyKind::Length(value.unwrap_or(0)));
        }
        Ok(BodyKind::None)
    }

    pub fn parse(mut input: &[u8]) -> Result<(HttpRequest, BodyKind), HttpError> {
        let (start, headers) = read_head(&mut input)?.ok_or(HttpError::UnexpectedEof)?;
        let mut parts = start.split(' ').filter(|s| !s.is_empty());
        let (method, target, version) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
            (Some(m), Some(t), Some(v), None) => (m, t, v),
            _ => return Err(HttpError::BadRequest(format!("malformed request line: {}", start))),
        };
        if !is_token(method) {
            return Err(HttpError::BadRequest(format!("invalid method: {}", method)));
        }
        if version != "HTTP/1.1" && version != "HTTP/1.0" {
            return Err(HttpError::BadRequest(format!("unsupported version: {}", version)));
        }
        if version == "HTTP/1.1" && !headers.contains("Host") {
            return Err(HttpError::BadRequest("missing Host header".into()));
        }
        let target = if let Some(rest) = target.strip_prefix("http://").or_else(|| target.strip_prefix("https://")) {
            match rest.find('/') {
                Some(i) => rest[i..].to_string(),
                None => "/".to_string(),
            }
        } else {
            target.to_string()
        };
        if !target.starts_with('/') && target != "*" {
            return Err(HttpError::BadRequest(format!("invalid request target: {}", target)));
        }
        let kind = body_kind(&headers)?;
        Ok((HttpRequest { method: method.to_string(), target, headers }, kind))
    }
}