Author: Andy Green Date: Sun Oct 04 21:20:55 2026 +0100 npro-test: hold the h1 parser to the heads of C's transcripts The h1-uri-*, h1-reqline-* and h1-header-past-limit transcripts are each one request to C's sansio-uri vhost, whose context limits a GET's target to 33 bytes and a User-Agent to 16. C refuses the request with a status, or its app answers 200 with the path lws decoded, a newline and the urlargs. So each says what C's parser made of its request, and npro's must make the same of it: the same refusal, or the same path and urlargs, joined as lws_hdr_copy() joins them. All 16 agree. Every server transcript's first request also parses the same split at each byte as whole, and the h1 client transcripts' response heads parse, with the framing headers C refuses them for read as C reads them: a Content-Length of "10abc", two Content-Lengths, a Transfer-Encoding list. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J diff --git a/Cargo.lock b/Cargo.lock index 058bc2d..857bf23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,4 +27,5 @@ name = "npro-test" version = "0.0.2" dependencies = [ "npro-core", + "npro-h1", ] diff --git a/crates/npro-test/Cargo.toml b/crates/npro-test/Cargo.toml index 315e51e..66eab58 100644 --- a/crates/npro-test/Cargo.toml +++ b/crates/npro-test/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dev-dependencies] npro-core = { path = "../npro-core" } +npro-h1 = { path = "../npro-h1" } [lints] workspace = true diff --git a/crates/npro-test/src/lib.rs b/crates/npro-test/src/lib.rs index 80d302b..0ec8ff2 100644 --- a/crates/npro-test/src/lib.rs +++ b/crates/npro-test/src/lib.rs @@ -12,7 +12,7 @@ test, expect( unused_crate_dependencies, - reason = "npro-core is a dev-dependency for the state tests in tests/, which the unit tests do not use" + reason = "npro-core and npro-h1 are dev-dependencies for the tests in tests/, which the unit tests do not use" ) )] diff --git a/crates/npro-test/tests/h1_heads.rs b/crates/npro-test/tests/h1_heads.rs new file mode 100644 index 0000000..3bea831 --- /dev/null +++ b/crates/npro-test/tests/h1_heads.rs @@ -0,0 +1,189 @@ +//! npro's h1 head parser against the heads of C's transcripts. +//! +//! The `h1-uri-*`, `h1-reqline-*` and `h1-header-past-limit` connections +//! are each one request to C's `sansio-uri` vhost, whose context limits a +//! GET's target to 33 bytes and a User-Agent to 16. C either refuses the +//! request with a status, or its app answers 200 with the path lws decoded, +//! a newline and the urlargs. So each says what C's parser made of its +//! request, and npro's must make the same of it: the same refusal, or the +//! same path and urlargs. +//! +//! The client connections' first rx is the response head. The ones whose +//! framing C refuses (`h1-client-cl-junk` and the like) are refused after +//! the head, by what the framing headers say; the head itself parses, and +//! `npro_h1::fields` must read them as C does. + +#![expect( + unused_crate_dependencies, + reason = "an integration test sees all of its crate's dependencies; this one uses npro-test and npro-h1" +)] + +// held to clippy's rules for tests +#[cfg(test)] +mod h1_heads { + use core::num::NonZeroU16; + + use npro_h1::fields::{content_length, transfer_encoding_is_chunked}; + use npro_h1::head::{Config, Head, Progress, Side}; + use npro_h1::table::DEFAULT_CAPACITY; + use npro_h1::token::Token; + use npro_test::{StepKind, Transcript, vendored}; + + /// The `sansio-uri` context's limits. + fn uri_config() -> Config { + Config::new() + .with_limit(Token::GetUri, NonZeroU16::new(33).unwrap()) + .with_limit(Token::UserAgent, NonZeroU16::new(16).unwrap()) + } + + fn first_rx(t: &Transcript) -> &[u8] { + t.steps + .iter() + .find_map(|s| match &s.kind { + StepKind::Rx(b) => Some(b.as_slice()), + StepKind::Tx(_) | StepKind::AppRx(_) | StepKind::Close => None, + }) + .unwrap() + } + + fn first_tx(t: &Transcript) -> &[u8] { + t.steps + .iter() + .find_map(|s| match &s.kind { + StepKind::Tx(b) => Some(b.as_slice()), + StepKind::Rx(_) | StepKind::AppRx(_) | StepKind::Close => None, + }) + .unwrap() + } + + /// What the request's answer says: its status, and for a 200, its body. + fn answered(tx: &[u8]) -> (u16, Vec) { + let status = core::str::from_utf8(&tx[9..12]).unwrap().parse().unwrap(); + let body = tx + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|n| tx[n..].strip_prefix(b"\r\n\r\n").unwrap().to_vec()) + .unwrap(); + (status, body) + } + + /// What C's `sansio-uri` app answers a request with: the path, a newline + /// and the urlargs, joined as `lws_hdr_copy()` joins them. + fn uri_app_body + AsMut<[u8]>>(h: &Head) -> Vec { + let t = h.table(); + let path = Token::METHODS + .iter() + .find_map(|m| t.first(*m)) + .unwrap() + .to_vec(); + let mut args = vec![0u8; t.total_len(Token::UriArgs)]; + t.copy(Token::UriArgs, &mut args).unwrap(); + [path, b"\n".to_vec(), args].concat() + } + + #[test] + #[cfg_attr(miri, ignore = "reads the transcripts: native runs keep it")] + fn requests_are_parsed_and_refused_as_c_does() { + let mut seen = 0; + for t in vendored().unwrap() { + let c = t.case.as_str(); + if !(c.starts_with("h1-uri-") + || c.starts_with("h1-reqline-") + || c == "h1-header-past-limit") + { + continue; + } + seen += 1; + let (status, body) = answered(first_tx(&t)); + let mut h = Head::new(vec![0u8; DEFAULT_CAPACITY], Side::Server, uri_config()).unwrap(); + match h.rx(first_rx(&t)) { + Ok(Progress::Complete { .. }) => { + assert_eq!(status, 200, "{c}: C refused what npro took"); + assert_eq!( + uri_app_body(&h).escape_ascii().to_string(), + body.escape_ascii().to_string(), + "{c}" + ); + } + Ok(p) => panic!("{c}: {p:?}"), + Err(r) => { + let a = r.answer().unwrap_or_else(|| panic!("{c}: {r} unanswered")); + assert_eq!(a.code(), status, "{c}: {r}"); + } + } + } + assert_eq!(seen, 16, "the uri and request line cases"); + } + + /// A request split anywhere is the same request. + #[test] + #[cfg_attr(miri, ignore = "reads the transcripts: native runs keep it")] + fn requests_split_anywhere_parse_the_same() { + for t in vendored().unwrap() { + if t.side != npro_test::Side::Server { + continue; + } + let rx = first_rx(&t); + let mut whole = + Head::new(vec![0u8; DEFAULT_CAPACITY], Side::Server, uri_config()).unwrap(); + let want = whole.rx(rx); + for at in 0..=rx.len() { + let mut h = + Head::new(vec![0u8; DEFAULT_CAPACITY], Side::Server, uri_config()).unwrap(); + let (a, b) = rx.split_at(at); + let got = match h.rx(a) { + Ok(Progress::More) => h.rx(b).map(|p| match p { + Progress::Complete { consumed } => Progress::Complete { + consumed: consumed + at, + }, + p @ (Progress::More | Progress::Fallback) => p, + }), + other => other, + }; + assert_eq!(got, want, "{} split at {at}", t.case); + if want.is_ok() { + assert_eq!(h.table().used(), whole.table().used(), "{}", t.case); + } + } + } + } + + #[test] + #[cfg_attr(miri, ignore = "reads the transcripts: native runs keep it")] + fn response_heads_and_their_framing_as_c_reads_them() { + let mut seen = 0; + for t in vendored().unwrap() { + let c = t.case.as_str(); + if !c.starts_with("h1-client-") || c.ends_with("digest-retry") { + continue; + } + seen += 1; + let mut h = + Head::new(vec![0u8; DEFAULT_CAPACITY], Side::Client, Config::new()).unwrap(); + let rx = first_rx(&t); + let Ok(Progress::Complete { consumed }) = h.rx(rx) else { + panic!("{c}: the head did not parse"); + }; + let tb = h.table(); + let status = tb.first(Token::Http).unwrap(); + let cl: Vec<&[u8]> = tb.fragments(Token::ContentLength).collect(); + match c { + "h1-client-get" => { + assert_eq!(status, b"200 OK"); + assert_eq!( + content_length(cl[0]), + Ok(u64::try_from(rx.len().checked_sub(consumed).unwrap()).unwrap()) + ); + } + "h1-client-cl-junk" => assert!(content_length(cl[0]).is_err()), + "h1-client-cl-twice" => assert_eq!(cl.len(), 2), + "h1-client-te-list" => assert!(!transfer_encoding_is_chunked(tb)), + "h1-client-head-chunked" => assert!(transfer_encoding_is_chunked(tb)), + "h1-client-head-cl" => assert_eq!(content_length(cl[0]), Ok(10)), + "h1-client-304-cl" => assert_eq!(status, b"304 Not Modified"), + _ => panic!("{c}: a new client case: say what its head is"), + } + } + assert_eq!(seen, 7, "the h1 client cases"); + } +}