| //! npro's h1 head parser against C's, head by head.
//!
//! `h1/c-heads.txt` is what C lws' `lws_parse()` made of every head in
//! `h1/requests/` and `h1/responses/`, and of variations of each, as a
//! server and as a client, in two configurations: see
//! `scripts/sync-c-h1.sh`, and `h1/c-heads.c`, which wrote it. For each,
//! npro's parser must come to the same verdict, and where the head parsed,
//! leave the same table: the same values in the same pieces, the same
//! unknown headers, and the same number of bytes used, so a head fills the
//! table at the same byte.
#![expect(
unused_crate_dependencies,
reason = "an integration test sees all of its crate's dependencies; this one uses only npro-h1"
)]
// held to clippy's rules for tests
#[cfg(test)]
mod h1_c {
use core::fmt::Write as _;
use core::num::NonZeroU16;
use npro_h1::chunked::{Chunk, Dechunk};
use npro_h1::head::{Cause, Config, Head, Progress, Side, UnknownMethod};
use npro_h1::table::HeaderTable;
use npro_h1::token::Token;
struct Case<'a> {
config: &'a str,
side: Side,
name: &'a str,
head: Vec<u8>,
verdict: &'a str,
}
/// The lines of `c-heads.txt` for `side`, split into their fields.
fn lines<'a>(text: &'a str, side: &str) -> Vec<[&'a str; 5]> {
text.lines()
.map(|l| {
let f: Vec<&str> = l.splitn(5, ' ').collect();
[f[0], f[1], f[2], f[3], f[4]]
})
.filter(|f| f[1] == side)
.collect()
}
/// A hex field, "-" for no bytes.
fn unhex(h: &str) -> Vec<u8> {
if h == "-" {
return Vec::new();
}
h.as_bytes()
.chunks(2)
.map(|h| u8::from_str_radix(core::str::from_utf8(h).unwrap(), 16).unwrap())
.collect()
}
fn cases(text: &str) -> Vec<Case<'_>> {
let mut all = Vec::new();
for (name, side) in [("server", Side::Server), ("client", Side::Client)] {
all.extend(lines(text, name).into_iter().map(|f| Case {
config: f[0],
side,
name: f[2],
head: unhex(f[3]),
verdict: f[4],
}));
}
all
}
fn config(name: &str) -> (usize, Config) {
let n = |v| NonZeroU16::new(v).unwrap();
match name {
"default" => (4096, Config::new()),
"tight" => (
512,
Config::new()
.with_limit(Token::GetUri, n(33))
.with_limit(Token::UserAgent, n(16))
.with_limit(Token::Host, n(24))
.with_limit(Token::Cookie, n(48))
.with_unknown_method(UnknownMethod::Fallback),
),
c => panic!("config {c}"),
}
}
fn hex(out: &mut String, b: &[u8]) {
for c in b {
write!(out, "{c:02x}").unwrap();
}
}
/// Bytes that are a field of their own, as `c-heads` writes them.
fn field(b: &[u8]) -> String {
if b.is_empty() {
return "-".into();
}
let mut s = String::new();
hex(&mut s, b);
s
}
/// The table as `c-heads` dumps C's.
fn dump(t: &HeaderTable<Vec<u8>>, used: usize) -> String {
let mut s = format!("used={used}");
for tok in Token::ALL {
if !t.is_present(tok) {
continue;
}
write!(s, " t{}=", tok.index()).unwrap();
for (i, f) in t.fragments(tok).enumerate() {
if i > 0 {
s.push(',');
}
hex(&mut s, f);
}
}
for (n, v) in t.unknown_headers() {
s.push_str(" u");
hex(&mut s, n);
s.push('=');
hex(&mut s, v);
}
s
}
/// What npro makes of a head, in `c-heads`' terms.
fn npro(case: &Case<'_>) -> (String, Option<Cause>) {
npro_in_pieces(case, &mut |rest| rest.len())
}
/// What npro makes of a head handed to it in pieces, `piece` saying how
/// long each is from what is left.
fn npro_in_pieces(
case: &Case<'_>,
piece: &mut dyn FnMut(&[u8]) -> usize,
) -> (String, Option<Cause>) {
let (cap, cfg) = config(case.config);
let mut table = HeaderTable::new(vec![0u8; cap]).unwrap();
if case.side == Side::Client {
// a client's table holds its own request first, as c-heads' does
table.create(Token::ClientPeerAddress, b"sansio").unwrap();
table.create(Token::ClientUri, b"/x").unwrap();
table.create(Token::ClientHost, b"sansio").unwrap();
table.create(Token::ClientMethod, b"GET").unwrap();
}
let mut h = Head::with_table(table, case.side, cfg);
let mut r = Ok(Progress::More);
let mut at = 0;
while at < case.head.len() {
let rest = &case.head[at..];
let n = piece(rest).min(rest.len());
r = h.rx(&rest[..n]).map(|p| match p {
Progress::Complete { consumed } => Progress::Complete {
consumed: consumed.checked_add(at).unwrap(),
},
p @ (Progress::More | Progress::Fallback) => p,
});
if !matches!(r, Ok(Progress::More)) {
break;
}
at = at.checked_add(n).unwrap();
}
let used = h.table().used();
match r {
Ok(Progress::Complete { consumed }) => (
format!("complete {consumed} {}", dump(h.table(), used)),
None,
),
Ok(Progress::More) => (format!("more {}", dump(h.table(), used)), None),
Ok(Progress::Fallback) => ("fallback".into(), None),
Err(e) => {
let v = match (case.side, e.answer(), e.cause()) {
(Side::Server, Some(a), _) => format!("refused {}", a.code()),
(Side::Client, _, Cause::Uri(_)) => "refused 0".into(),
(Side::Client, _, Cause::HeadTooLarge | Cause::UriTooLong) => "toolarge".into(),
(Side::Server, None, _) | (Side::Client, _, _) => "fail".into(),
};
(v, Some(e.cause()))
}
}
}
#[test]
#[cfg_attr(miri, ignore = "thousands of heads: native runs keep it")]
fn every_head_parses_as_c_parses_it() {
let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/h1/c-heads.txt"))
.unwrap();
let all = cases(&text);
assert!(all.len() > 1000, "{} heads", all.len());
let mut wrong = Vec::new();
for case in &all {
let (got, cause) = npro(case);
if got == case.verdict {
continue;
}
wrong.push(format!(
"{} {:?} {}: {}\n head: {}\n C: {}\n npro: {} ({cause:?})",
case.config,
case.side,
case.name,
wrong.len(),
case.head.escape_ascii(),
case.verdict,
got
));
}
assert!(
wrong.is_empty(),
"{} of {} heads differ:\n{}",
wrong.len(),
all.len(),
wrong
.iter()
.take(20)
.cloned()
.collect::<Vec<_>>()
.join("\n")
);
}
/// A head in pieces parses as it does whole, wherever it is split: the
/// oracle that found the most bugs in C's newer parsers. Each head is
/// split three ways, into pieces of 0 to 16 bytes from a seeded generator.
#[test]
#[cfg_attr(miri, ignore = "thousands of heads: native runs keep it")]
fn every_head_parses_the_same_in_pieces() {
let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/h1/c-heads.txt"))
.unwrap();
// C's rand(), as npro-fuzz's splitting uses
let mut state = 1u32;
let mut next = move |_: &[u8]| {
state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
usize::try_from((state >> 16) % 17).unwrap()
};
for case in cases(&text) {
let whole = npro(&case);
for _ in 0..3 {
assert_eq!(
npro_in_pieces(&case, &mut next),
whole,
"{} {:?} {}",
case.config,
case.side,
case.name
);
}
}
}
/// What npro's dechunker makes of a body, in `c-heads`' terms.
fn dechunked(body: &[u8]) -> String {
let mut d = Dechunk::new();
let (mut data, mut at) = (Vec::new(), 0usize);
loop {
let Ok(step) = d.step(&body[at..]) else {
return "fail".into();
};
at = at.checked_add(step.consumed).unwrap();
match step.chunk {
Chunk::Data(b) => data.extend_from_slice(b),
Chunk::End => return format!("end {at} {}", field(&data)),
Chunk::More if at == body.len() => return format!("more {}", field(&data)),
Chunk::More => {}
}
}
}
#[test]
#[cfg_attr(miri, ignore = "hundreds of bodies: native runs keep it")]
fn every_chunked_body_is_framed_as_c_frames_it() {
let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/h1/c-heads.txt"))
.unwrap();
let bodies = lines(&text, "chunked");
assert!(bodies.len() > 100, "{} bodies", bodies.len());
for f in bodies {
let body = unhex(f[3]);
assert_eq!(dechunked(&body), f[4], "{}: {}", f[2], body.escape_ascii());
}
}
}
|