Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
Author[]Andy Green <andy@warmcat.com> 2026-10-04 20:20 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-05 06:32 UTC
Treefa866d6006e020603c65fc5139f1e7d763cf617f   Raw Patch
 
npro-test: hold the h1 parser to the heads of C's transcripts
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) <noreply@anthropic.com>
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<u8>) { + 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<S: AsRef<[u8]> + AsMut<[u8]>>(h: &Head<S>) -> Vec<u8> { + 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"); + } +}
Page fetched 0s ago, creation time: 7ms (vhost etag hits: 0%, cache hits: 0%)