| Author | Andy Green <andy@warmcat.com> 2026-10-04 20:21 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 06:32 UTC | | Tree | aaed2432255247b98f95b57e9f42c8a56a31b568 Raw Patch | | | fuzz: the h1 parser as server and client, and the dechunker | fuzz: the h1 parser as server and client, and the dechunker
Three targets, each with an oracle, as the port plan's phase 1c asks:
- h1-request, npro_h1::head as a server, and h1-response, as a client.
The first byte chooses how the rest is split and, with its top bit, a
256 byte table with token limits, so libFuzzer reaches the limits. A
head in one piece and in pieces must come to the same verdict and leave
the same table; a refused head must stay refused; no value may hold a
CR, LF or NUL; and a complete request's path from / must be normal, no
//, /./ or /../ step and no /. or /.. at its end, which is what C's
decoder is for.
- chunked, npro_h1::chunked::Dechunk, against a second reading of RFC 9112
7.1 with C's bounds, written apart from the decoder, over the whole
body by index: the same data, ending at the same byte, or refused, in
one piece and in pieces.
What these cannot see, a verdict wrong the same way whole and in pieces,
is what npro-test's h1_c test against C is for.
The seeds are C's fuzz/fuzz-h1/seeds, behind a control byte of 0 and
named as there, and some of npro-test's h1 corpus, a tight-* seed with
the top bit set. regress-refused-mid-dot.http is the one finding of the
first run, in the oracle: it held a refused head's half-built path to
the rule for complete ones. Five minutes of each since found nothing,
in 0.9M, 0.4M and 7.1M runs.
Planted bugs fail the smoke tests: the dechunker's bound one less (once
a seed sits on it, skip-at-bound.txt), and // no longer swallowed.
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 857bf23..9be86bd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -15,6 +15,7 @@ name = "npro-fuzz"
version = "0.0.2"
dependencies = [
"npro-core",
+ "npro-h1",
"npro-test",
]
diff --git a/crates/npro-fuzz/Cargo.toml b/crates/npro-fuzz/Cargo.toml
index c3450b1..776a308 100644
--- a/crates/npro-fuzz/Cargo.toml
+++ b/crates/npro-fuzz/Cargo.toml
@@ -11,6 +11,7 @@ repository.workspace = true
[dependencies]
npro-core = { path = "../npro-core" }
+npro-h1 = { path = "../npro-h1" }
npro-test = { path = "../npro-test" }
[dev-dependencies]
diff --git a/crates/npro-fuzz/src/h1.rs b/crates/npro-fuzz/src/h1.rs
new file mode 100644
index 0000000..558c2e9
--- /dev/null
+++ b/crates/npro-fuzz/src/h1.rs
@@ -0,0 +1,378 @@
+//! The h1 targets: the head parser as a server and as a client, and the
+//! dechunker.
+
+use core::num::NonZeroU16;
+
+use npro_h1::chunked::{Chunk, Dechunk, SKIP_MAX};
+use npro_h1::head::{Config, Head, Progress, Refused, Side, UnknownMethod};
+use npro_h1::table::{DEFAULT_CAPACITY, HeaderTable};
+use npro_h1::token::Token;
+
+use crate::targets::{Pieces, control, finding};
+
+/// The configuration the input's control byte chooses: C's defaults, or a
+/// small table with token limits and unknown methods falling back, so
+/// limits are reached by inputs libFuzzer can grow to.
+fn config(ctl: u8) -> (usize, Config) {
+ if ctl & 0x80 == 0 {
+ return (DEFAULT_CAPACITY, Config::new());
+ }
+ let limit = |n| NonZeroU16::new(n).unwrap_or(NonZeroU16::MIN);
+ (
+ 256,
+ Config::new()
+ .with_limit(Token::GetUri, limit(33))
+ .with_limit(Token::UserAgent, limit(16))
+ .with_limit(Token::Host, limit(24))
+ .with_limit(Token::Cookie, limit(48))
+ .with_unknown_method(UnknownMethod::Fallback),
+ )
+}
+
+/// Everything a parsed head is: the verdict, and the table it left.
+#[derive(Debug, PartialEq, Eq)]
+struct Parsed {
+ verdict: Result<Progress, Refused>,
+ used: usize,
+ tokens: Vec<(Token, Vec<Vec<u8>>)>,
+ unknown: Vec<(Vec<u8>, Vec<u8>)>,
+}
+
+fn parsed(h: &Head<Vec<u8>>, verdict: Result<Progress, Refused>) -> Parsed {
+ let t = h.table();
+ Parsed {
+ verdict,
+ used: t.used(),
+ tokens: Token::ALL
+ .into_iter()
+ .filter(|tok| t.is_present(*tok))
+ .map(|tok| (tok, t.fragments(tok).map(<[u8]>::to_vec).collect()))
+ .collect(),
+ unknown: t
+ .unknown_headers()
+ .map(|(n, v)| (n.to_vec(), v.to_vec()))
+ .collect(),
+ }
+}
+
+fn head(target: &str, side: Side, cap: usize, config: Config) -> Head<Vec<u8>> {
+ let mut table = HeaderTable::new(vec![0u8; cap])
+ .unwrap_or_else(|e| finding(target, format_args!("a table of {cap}: {e}")));
+ if side == Side::Client {
+ // a client's table holds its own request first
+ for (t, v) in [(Token::ClientUri, &b"/x"[..]), (Token::ClientHost, b"h")] {
+ if let Err(e) = table.create(t, v) {
+ finding(target, format_args!("client's own {t:?}: {e}"));
+ }
+ }
+ }
+ Head::with_table(table, side, config)
+}
+
+/// The head given in one piece.
+fn whole(target: &str, side: Side, ctl: u8, bytes: &[u8]) -> Parsed {
+ let (cap, config) = config(ctl);
+ let mut h = head(target, side, cap, config);
+ let verdict = if bytes.is_empty() {
+ Ok(Progress::More)
+ } else {
+ h.rx(bytes)
+ };
+ // a refused head stays refused, saying the same
+ if let Err(r) = verdict {
+ if h.rx(b"\r\n\r\n") != Err(r) {
+ finding(target, format_args!("refused with {r}, then not"));
+ }
+ }
+ parsed(&h, verdict)
+}
+
+/// The head given in pieces.
+fn in_pieces(target: &str, side: Side, ctl: u8, bytes: &[u8]) -> Parsed {
+ let (cap, config) = config(ctl);
+ let mut h = head(target, side, cap, config);
+ let mut verdict = Ok(Progress::More);
+ let mut at = 0usize;
+ for piece in Pieces::new(ctl, bytes) {
+ verdict = h.rx(piece).map(|p| match p {
+ Progress::Complete { consumed } => Progress::Complete {
+ consumed: consumed.saturating_add(at),
+ },
+ Progress::More | Progress::Fallback => p,
+ });
+ if verdict != Ok(Progress::More) {
+ break;
+ }
+ at = at.saturating_add(piece.len());
+ }
+ parsed(&h, verdict)
+}
+
+/// What every parsed head must be, whatever the bytes were.
+fn check(target: &str, p: &Parsed, len: usize, cap: usize) {
+ if let Ok(Progress::Complete { consumed }) = p.verdict {
+ if consumed == 0 || consumed > len {
+ finding(target, format_args!("consumed {consumed} of {len}"));
+ }
+ }
+ if p.used > cap {
+ finding(target, format_args!("used {} of {cap}", p.used));
+ }
+ // the bytes that end a line, or every value, are never in one
+ let bad = |v: &[u8]| v.iter().any(|c| matches!(c, b'\r' | b'\n' | 0));
+ for (t, frags) in &p.tokens {
+ if frags.iter().any(|f| bad(f)) {
+ finding(target, format_args!("{t:?} has a CR, LF or NUL: {frags:?}"));
+ }
+ }
+ for (n, v) in &p.unknown {
+ if bad(v) || n.contains(&0) {
+ finding(target, format_args!("unknown {n:?} = {v:?}"));
+ }
+ }
+ // a complete request's path is never above its root, nor has a step
+ // that is not one (a refused head's is wherever it stopped, and goes
+ // nowhere)
+ if !matches!(p.verdict, Ok(Progress::Complete { .. })) {
+ return;
+ }
+ for (t, frags) in &p.tokens {
+ if !t.is_method() {
+ continue;
+ }
+ for path in frags.iter().filter(|f| f.first() == Some(&b'/')) {
+ let steps = [&b"//"[..], b"/./", b"/../"];
+ if steps.iter().any(|s| path.windows(s.len()).any(|w| w == *s))
+ || path.ends_with(b"/.")
+ || path.ends_with(b"/..")
+ {
+ finding(
+ target,
+ format_args!("{t:?} path {} is not normal", path.escape_ascii()),
+ );
+ }
+ }
+ }
+}
+
+fn heads(target: &str, side: Side, data: &[u8]) {
+ let (ctl, bytes) = control(data);
+ let one = whole(target, side, ctl, bytes);
+ check(target, &one, bytes.len(), config(ctl).0);
+ let pieces = in_pieces(target, side, ctl, bytes);
+ if pieces != one {
+ finding(
+ target,
+ format_args!("in one piece:\n{one:?}\nin pieces:\n{pieces:?}"),
+ );
+ }
+}
+
+/// A request head as a server parses it.
+///
+/// The first byte chooses how the rest is split, and with its top bit, a
+/// small table with token limits. In one piece and in pieces, the head
+/// must come to the same verdict and leave the same table; a refused head
+/// must stay refused; no value may hold a CR, LF or NUL; and a request
+/// path from the root must be normal, with no `//`, `/./` or `/../` step
+/// and no `/.` or `/..` at its end.
+pub fn h1_request(data: &[u8]) {
+ heads("h1-request", Side::Server, data);
+}
+
+/// A response head as a client parses it: as [`h1_request`], from the
+/// client's side.
+pub fn h1_response(data: &[u8]) {
+ heads("h1-response", Side::Client, data);
+}
+
+/// What a chunked body is: its data, and where it ended, or that it is not
+/// over, or that it cannot be framed.
+#[derive(Debug, PartialEq, Eq)]
+enum Body {
+ End { consumed: usize, data: Vec<u8> },
+ More { data: Vec<u8> },
+ Refused,
+}
+
+/// The oracle: RFC 9112 7.1's grammar with C's bounds, read off the whole
+/// body at once, by index, written apart from the decoder.
+struct Reference<'a> {
+ body: &'a [u8],
+ at: usize,
+ skipped: u16,
+ data: Vec<u8>,
+}
+
+/// Why the reference stopped: the body ended, or it is refused.
+enum Stop {
+ Short,
+ Refused,
+}
+
+impl Reference<'_> {
+ fn peek(&self) -> Result<u8, Stop> {
+ self.body.get(self.at).copied().ok_or(Stop::Short)
+ }
+
+ fn take(&mut self) -> Result<u8, Stop> {
+ let c = self.peek()?;
+ self.at = self.at.saturating_add(1);
+ Ok(c)
+ }
+
+ fn expect(&mut self, want: u8) -> Result<(), Stop> {
+ if self.take()? == want {
+ Ok(())
+ } else {
+ Err(Stop::Refused)
+ }
+ }
+
+ const fn skip(&mut self) -> Result<(), Stop> {
+ self.skipped = self.skipped.saturating_add(1);
+ if self.skipped > SKIP_MAX {
+ return Err(Stop::Refused);
+ }
+ Ok(())
+ }
+
+ /// The bytes of an extension or a trailer line, to the CR its line
+ /// ends with, each counted. A LF before it is refused.
+ fn skip_line(&mut self) -> Result<(), Stop> {
+ loop {
+ match self.peek()? {
+ b'\r' => return Ok(()),
+ b'\n' => return Err(Stop::Refused),
+ _ => {
+ self.skip()?;
+ self.at = self.at.saturating_add(1);
+ }
+ }
+ }
+ }
+
+ fn chunk_size(&mut self) -> Result<u32, Stop> {
+ let mut size = 0u32;
+ let mut digits = 0usize;
+ while let Some(d) = char::from(self.peek()?).to_digit(16) {
+ if size > (0x7fff_ffff - 15) / 16 {
+ return Err(Stop::Refused);
+ }
+ size = (size << 4) | d;
+ digits = digits.saturating_add(1);
+ self.at = self.at.saturating_add(1);
+ }
+ if digits == 0 {
+ return Err(Stop::Refused);
+ }
+ Ok(size)
+ }
+
+ fn body(&mut self) -> Result<usize, Stop> {
+ loop {
+ let size = self.chunk_size()?;
+ match self.peek()? {
+ b'\r' => {}
+ b';' | b' ' | b'\t' => self.skip_line()?,
+ _ => return Err(Stop::Refused),
+ }
+ self.expect(b'\r')?;
+ self.expect(b'\n')?;
+ if size == 0 {
+ return self.trailers();
+ }
+ let want = usize::try_from(size).unwrap_or(usize::MAX);
+ let rest = self.body.get(self.at..).unwrap_or_default();
+ let got = rest.get(..want.min(rest.len())).unwrap_or_default();
+ self.data.extend_from_slice(got);
+ self.at = self.at.saturating_add(got.len());
+ if got.len() < want {
+ return Err(Stop::Short);
+ }
+ self.expect(b'\r')?;
+ self.expect(b'\n')?;
+ }
+ }
+
+ fn trailers(&mut self) -> Result<usize, Stop> {
+ loop {
+ if self.peek()? == b'\r' {
+ self.at = self.at.saturating_add(1);
+ self.expect(b'\n')?;
+ return Ok(self.at);
+ }
+ self.skip_line()?;
+ // the CR ending a trailer line counts, its LF does not
+ self.skip()?;
+ self.at = self.at.saturating_add(1);
+ self.expect(b'\n')?;
+ }
+ }
+}
+
+fn reference(body: &[u8]) -> Body {
+ let mut r = Reference {
+ body,
+ at: 0,
+ skipped: 0,
+ data: Vec::new(),
+ };
+ match r.body() {
+ Ok(consumed) => Body::End {
+ consumed,
+ data: r.data,
+ },
+ Err(Stop::Short) => Body::More { data: r.data },
+ Err(Stop::Refused) => Body::Refused,
+ }
+}
+
+/// The decoder, handed the body in the pieces `pieces` gives.
+fn decoded<'a>(pieces: impl Iterator<Item = &'a [u8]>) -> Body {
+ let mut d = Dechunk::new();
+ let (mut data, mut at) = (Vec::new(), 0usize);
+ for piece in pieces {
+ let mut rest = piece;
+ loop {
+ let Ok(step) = d.step(rest) else {
+ return Body::Refused;
+ };
+ at = at.saturating_add(step.consumed);
+ rest = rest.get(step.consumed..).unwrap_or_default();
+ match step.chunk {
+ Chunk::Data(b) => data.extend_from_slice(b),
+ Chunk::End => return Body::End { consumed: at, data },
+ Chunk::More => break,
+ }
+ if rest.is_empty() {
+ break;
+ }
+ }
+ }
+ Body::More { data }
+}
+
+/// A chunked body, as a server's request or a client's response has.
+///
+/// The first byte chooses how the rest is split. Decoded in one piece and
+/// in pieces, it must be what a second reading of RFC 9112 7.1 with C's
+/// bounds makes of it: the same data, ending at the same byte, or refused.
+pub fn chunked(data: &[u8]) {
+ let (ctl, body) = control(data);
+ let want = reference(body);
+ let one = decoded(core::iter::once(body));
+ if one != want {
+ finding(
+ "chunked",
+ format_args!("in one piece {one:?}, the grammar says {want:?}"),
+ );
+ }
+ let pieces = decoded(Pieces::new(ctl, body));
+ if pieces != want {
+ finding(
+ "chunked",
+ format_args!("in pieces {pieces:?}, the grammar says {want:?}"),
+ );
+ }
+}
diff --git a/crates/npro-fuzz/src/lib.rs b/crates/npro-fuzz/src/lib.rs
index 82a53f6..bba862e 100644
--- a/crates/npro-fuzz/src/lib.rs
+++ b/crates/npro-fuzz/src/lib.rs
@@ -20,8 +20,10 @@
#![forbid(unsafe_code)]
+mod h1;
mod targets;
+pub use h1::{chunked, h1_request, h1_response};
pub use targets::{base64, sha1, transcript, utf8};
/// A fuzz target: its name is the libFuzzer target's, the seed directory's
@@ -45,11 +47,25 @@ pub enum Target {
Base64,
/// [`transcript`]: npro-test's transcript reader.
Transcript,
+ /// [`h1_request`]: the h1 head parser, as a server.
+ H1Request,
+ /// [`h1_response`]: the h1 head parser, as a client.
+ H1Response,
+ /// [`chunked`]: the chunked transfer coding's decoder.
+ Chunked,
}
impl Target {
/// Every target.
- pub const ALL: [Self; 4] = [Self::Utf8, Self::Sha1, Self::Base64, Self::Transcript];
+ pub const ALL: [Self; 7] = [
+ Self::Utf8,
+ Self::Sha1,
+ Self::Base64,
+ Self::Transcript,
+ Self::H1Request,
+ Self::H1Response,
+ Self::Chunked,
+ ];
/// The target's name.
#[must_use]
@@ -59,6 +75,9 @@ impl Target {
Self::Sha1 => "sha1",
Self::Base64 => "base64",
Self::Transcript => "transcript",
+ Self::H1Request => "h1-request",
+ Self::H1Response => "h1-response",
+ Self::Chunked => "chunked",
}
}
@@ -74,6 +93,9 @@ impl Target {
Self::Sha1 => sha1(data),
Self::Base64 => base64(data),
Self::Transcript => transcript(data),
+ Self::H1Request => h1_request(data),
+ Self::H1Response => h1_response(data),
+ Self::Chunked => chunked(data),
}
}
}
diff --git a/crates/npro-fuzz/src/targets.rs b/crates/npro-fuzz/src/targets.rs
index 3f014ac..db10119 100644
--- a/crates/npro-fuzz/src/targets.rs
+++ b/crates/npro-fuzz/src/targets.rs
@@ -15,26 +15,26 @@ use npro_test::{MAX_BYTES, MAX_STEPS, Transcript};
reason = "a panic is how a fuzz target reports a finding to libFuzzer"
)]
#[cold]
-fn finding(target: &str, what: fmt::Arguments<'_>) -> ! {
+pub(crate) fn finding(target: &str, what: fmt::Arguments<'_>) -> ! {
panic!("{target}: {what}")
}
/// The input's first byte, which chooses how a target splits the rest,
/// and the rest.
-fn control(data: &[u8]) -> (u8, &[u8]) {
+pub(crate) fn control(data: &[u8]) -> (u8, &[u8]) {
data.split_first().map_or((0, data), |(c, rest)| (*c, rest))
}
/// `bytes` split into pieces of 0 to 16 bytes, the sizes drawn from a
/// small generator seeded by `ctl`. Pieces are what arrives in one read,
/// so empty ones are included: a reader can be handed nothing.
-struct Pieces<'a> {
+pub(crate) struct Pieces<'a> {
rest: &'a [u8],
state: u32,
}
impl<'a> Pieces<'a> {
- fn new(ctl: u8, bytes: &'a [u8]) -> Self {
+ pub(crate) fn new(ctl: u8, bytes: &'a [u8]) -> Self {
Self {
rest: bytes,
state: u32::from(ctl),
diff --git a/crates/npro-fuzz/tests/smoke.rs b/crates/npro-fuzz/tests/smoke.rs
index a9fcbdd..230cf2e 100644
--- a/crates/npro-fuzz/tests/smoke.rs
+++ b/crates/npro-fuzz/tests/smoke.rs
@@ -195,9 +195,48 @@ fn transcript() {
.unwrap();
}
+/// A seed, sometimes changed, after a split byte which may also choose the
+/// target's configuration.
+fn seeded(r: &mut SeededRandom, seeds: &[Vec<u8>]) -> Vec<u8> {
+ let seed = seeds
+ .get(index_below(r, seeds.len()))
+ .map_or(&[][..], Vec::as_slice);
+ // past the seed's own control byte
+ let body = seed.get(1..).unwrap_or_default();
+ let body = if below(r, 4) == 0 {
+ body.to_vec()
+ } else {
+ mutated(r, body)
+ };
+ split_then(r, body)
+}
+
+#[test]
+fn h1_request() {
+ smoke(Target::H1Request, seeded).unwrap();
+}
+
+#[test]
+fn h1_response() {
+ smoke(Target::H1Response, seeded).unwrap();
+}
+
+#[test]
+fn chunked() {
+ smoke(Target::Chunked, seeded).unwrap();
+}
+
#[test]
fn every_target_has_a_smoke_test() {
// the tests above, by name: a new target needs its own
- let tested = ["utf8", "sha1", "base64", "transcript"];
+ let tested = [
+ "utf8",
+ "sha1",
+ "base64",
+ "transcript",
+ "h1-request",
+ "h1-response",
+ "chunked",
+ ];
assert_eq!(Target::ALL.map(Target::name), tested);
}
diff --git a/docs/fuzzing.md b/docs/fuzzing.md
index 249397d..7d2e732 100644
--- a/docs/fuzzing.md
+++ b/docs/fuzzing.md
@@ -22,13 +22,18 @@ Fuzzing runs in three places:
| `sha1` | `npro_core::sha1::Sha1`, for the ws accept | the digest of the message fed in pieces equals the digest of it in one go |
| `base64` | `npro_core::base64::encode` | a strict decoder in the harness gets the input back; exactly `encoded_len` bytes are used and no more written; a buffer one byte short, or empty, is refused with nothing written |
| `transcript` | npro-test's transcript reader | whatever it accepts keeps its limits, and step times never go backwards |
+| `h1-request` | `npro_h1::head` as a server | the head in one piece and in pieces comes to the same verdict and leaves the same table; a refused head stays refused; no value holds a CR, LF or NUL; a complete request's path from `/` has no `//`, `/./` or `/../` step and no `/.` or `/..` at its end. The top bit of the first byte picks a 256 byte table with token limits, so limits are within reach |
+| `h1-response` | `npro_h1::head` as a client | as `h1-request` |
+| `chunked` | `npro_h1::chunked::Dechunk` | a second reading of RFC 9112 7.1 with C's bounds, written apart from the decoder: the same data, ending at the same byte, or refused, in one piece and in pieces |
A target that splits its input to feed it in pieces takes the split from
the input's first byte, so libFuzzer explores the split like the rest of
the input.
-The h1 parser's targets come next, in phase 1c of
-[port-plan.md](port-plan.md), seeded from the C library's corpora.
+What the h1 targets cannot see, a verdict that is wrong the same way
+whole and in pieces, is what `crates/npro-test/tests/h1_c.rs` checks:
+npro's parser against C's over thousands of heads
+([its README](../crates/npro-test/h1/README.md)).
## Where things are
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
index 7da7fcb..40c918f 100644
--- a/fuzz/Cargo.lock
+++ b/fuzz/Cargo.lock
@@ -78,6 +78,7 @@ name = "npro-fuzz"
version = "0.0.2"
dependencies = [
"npro-core",
+ "npro-h1",
"npro-test",
]
@@ -90,6 +91,10 @@ dependencies = [
]
[[package]]
+name = "npro-h1"
+version = "0.0.2"
+
+[[package]]
name = "npro-test"
version = "0.0.2"
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index c767bba..1757568 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -47,6 +47,27 @@ test = false
doc = false
bench = false
+[[bin]]
+name = "h1-request"
+path = "fuzz_targets/h1_request.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "h1-response"
+path = "fuzz_targets/h1_response.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "chunked"
+path = "fuzz_targets/chunked.rs"
+test = false
+doc = false
+bench = false
+
[lints.rust]
unsafe_code = "forbid"
unexpected_cfgs = "deny"
diff --git a/fuzz/deny.toml b/fuzz/deny.toml
index 03379ae..2d4afe2 100644
--- a/fuzz/deny.toml
+++ b/fuzz/deny.toml
@@ -35,6 +35,7 @@ allow = [
# the fuzz targets, and the npro crates they drive
"npro-fuzz-targets",
"npro-fuzz",
+ "npro-h1",
"npro-core",
"npro-test",
diff --git a/fuzz/fuzz_targets/chunked.rs b/fuzz/fuzz_targets/chunked.rs
new file mode 100644
index 0000000..8d114b9
--- /dev/null
+++ b/fuzz/fuzz_targets/chunked.rs
@@ -0,0 +1,5 @@
+//! libFuzzer target for [`npro_fuzz::Target::Chunked`].
+
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::Chunked.run(data));
diff --git a/fuzz/fuzz_targets/h1_request.rs b/fuzz/fuzz_targets/h1_request.rs
new file mode 100644
index 0000000..65e45f3
--- /dev/null
+++ b/fuzz/fuzz_targets/h1_request.rs
@@ -0,0 +1,5 @@
+//! libFuzzer target for [`npro_fuzz::Target::H1Request`].
+
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::H1Request.run(data));
diff --git a/fuzz/fuzz_targets/h1_response.rs b/fuzz/fuzz_targets/h1_response.rs
new file mode 100644
index 0000000..f7e4ff9
--- /dev/null
+++ b/fuzz/fuzz_targets/h1_response.rs
@@ -0,0 +1,5 @@
+//! libFuzzer target for [`npro_fuzz::Target::H1Response`].
+
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::H1Response.run(data));
diff --git a/fuzz/seeds/.gitattributes b/fuzz/seeds/.gitattributes
new file mode 100644
index 0000000..8e4df57
--- /dev/null
+++ b/fuzz/seeds/.gitattributes
@@ -0,0 +1,5 @@
+# inputs as they go on the wire, CRLFs and all: no tool may change their
+# line ends, and a patch carries them as bytes
+h1-request/** binary
+h1-response/** binary
+chunked/** binary
diff --git a/fuzz/seeds/README.md b/fuzz/seeds/README.md
index 0604bac..2432fc5 100644
--- a/fuzz/seeds/README.md
+++ b/fuzz/seeds/README.md
@@ -14,6 +14,16 @@ The `transcript` target also starts from every transcript in
Seeds are small and readable on purpose: each is a case worth starting from,
named for what it is. What the fuzzer finds goes in its corpus, which is
-kept between runs, not here ([docs/fuzzing.md](../../docs/fuzzing.md)). When the protocol crates arrive, their
-targets' seeds come from the C library's corpora (`fuzz/fuzz-*/seeds` in
-the C tree), copied with where they came from.
+kept between runs, not here ([docs/fuzzing.md](../../docs/fuzzing.md)).
+The protocol crates' targets start from the C library's corpora
+(`fuzz/fuzz-*/seeds` in the C tree), copied with where they came from:
+
+- `h1-request`: C's `fuzz/fuzz-h1/seeds`, each behind a control byte of
+ 0, named as there (`absuri.http`, `get.http`...), and some of
+ `crates/npro-test/h1/requests/`, named as there;
+- `h1-response` and `chunked`: some of `crates/npro-test/h1/responses/`
+ and `chunked/`.
+
+A `tight-*` seed's control byte has its top bit set, choosing the small
+table with token limits. A seed named `regress-*` is an input the fuzzer
+once failed on, kept so it is tried every run, as in C.
diff --git a/fuzz/seeds/chunked/ext-and-trailers.txt b/fuzz/seeds/chunked/ext-and-trailers.txt
new file mode 100644
index 0000000..ab69d41
Binary files /dev/null and b/fuzz/seeds/chunked/ext-and-trailers.txt differ
diff --git a/fuzz/seeds/chunked/ext.txt b/fuzz/seeds/chunked/ext.txt
new file mode 100644
index 0000000..1dc4c12
Binary files /dev/null and b/fuzz/seeds/chunked/ext.txt differ
diff --git a/fuzz/seeds/chunked/leading-zeros.txt b/fuzz/seeds/chunked/leading-zeros.txt
new file mode 100644
index 0000000..6ea3757
Binary files /dev/null and b/fuzz/seeds/chunked/leading-zeros.txt differ
diff --git a/fuzz/seeds/chunked/one.txt b/fuzz/seeds/chunked/one.txt
new file mode 100644
index 0000000..cc8b17e
Binary files /dev/null and b/fuzz/seeds/chunked/one.txt differ
diff --git a/fuzz/seeds/chunked/size-at-limit.txt b/fuzz/seeds/chunked/size-at-limit.txt
new file mode 100644
index 0000000..37792c7
Binary files /dev/null and b/fuzz/seeds/chunked/size-at-limit.txt differ
diff --git a/fuzz/seeds/chunked/skip-at-bound.txt b/fuzz/seeds/chunked/skip-at-bound.txt
new file mode 100644
index 0000000..a02d87f
Binary files /dev/null and b/fuzz/seeds/chunked/skip-at-bound.txt differ
diff --git a/fuzz/seeds/chunked/trailers.txt b/fuzz/seeds/chunked/trailers.txt
new file mode 100644
index 0000000..408d789
Binary files /dev/null and b/fuzz/seeds/chunked/trailers.txt differ
diff --git a/fuzz/seeds/chunked/two.txt b/fuzz/seeds/chunked/two.txt
new file mode 100644
index 0000000..8aaab0f
Binary files /dev/null and b/fuzz/seeds/chunked/two.txt differ
diff --git a/fuzz/seeds/chunked/unfinished.txt b/fuzz/seeds/chunked/unfinished.txt
new file mode 100644
index 0000000..65191e3
Binary files /dev/null and b/fuzz/seeds/chunked/unfinished.txt differ
diff --git a/fuzz/seeds/h1-request/absuri.http b/fuzz/seeds/h1-request/absuri.http
new file mode 100644
index 0000000..27d127c
Binary files /dev/null and b/fuzz/seeds/h1-request/absuri.http differ
diff --git a/fuzz/seeds/h1-request/chunked.http b/fuzz/seeds/h1-request/chunked.http
new file mode 100644
index 0000000..0b6131c
Binary files /dev/null and b/fuzz/seeds/h1-request/chunked.http differ
diff --git a/fuzz/seeds/h1-request/duphdrs.http b/fuzz/seeds/h1-request/duphdrs.http
new file mode 100644
index 0000000..070408d
Binary files /dev/null and b/fuzz/seeds/h1-request/duphdrs.http differ
diff --git a/fuzz/seeds/h1-request/get.http b/fuzz/seeds/h1-request/get.http
new file mode 100644
index 0000000..a1042da
Binary files /dev/null and b/fuzz/seeds/h1-request/get.http differ
diff --git a/fuzz/seeds/h1-request/headers-bare-cr.http b/fuzz/seeds/h1-request/headers-bare-cr.http
new file mode 100644
index 0000000..e2b7202
Binary files /dev/null and b/fuzz/seeds/h1-request/headers-bare-cr.http differ
diff --git a/fuzz/seeds/h1-request/headers-many.http b/fuzz/seeds/h1-request/headers-many.http
new file mode 100644
index 0000000..0216fdc
Binary files /dev/null and b/fuzz/seeds/h1-request/headers-many.http differ
diff --git a/fuzz/seeds/h1-request/headers-repeated.http b/fuzz/seeds/h1-request/headers-repeated.http
new file mode 100644
index 0000000..2fd66a6
Binary files /dev/null and b/fuzz/seeds/h1-request/headers-repeated.http differ
diff --git a/fuzz/seeds/h1-request/headers-unknown.http b/fuzz/seeds/h1-request/headers-unknown.http
new file mode 100644
index 0000000..a6f8072
Binary files /dev/null and b/fuzz/seeds/h1-request/headers-unknown.http differ
diff --git a/fuzz/seeds/h1-request/leading-empty-2.http b/fuzz/seeds/h1-request/leading-empty-2.http
new file mode 100644
index 0000000..01256f6
Binary files /dev/null and b/fuzz/seeds/h1-request/leading-empty-2.http differ
diff --git a/fuzz/seeds/h1-request/longvalue.http b/fuzz/seeds/h1-request/longvalue.http
new file mode 100644
index 0000000..f812f10
Binary files /dev/null and b/fuzz/seeds/h1-request/longvalue.http differ
diff --git a/fuzz/seeds/h1-request/method-unknown.http b/fuzz/seeds/h1-request/method-unknown.http
new file mode 100644
index 0000000..847964c
Binary files /dev/null and b/fuzz/seeds/h1-request/method-unknown.http differ
diff --git a/fuzz/seeds/h1-request/post-cl.http b/fuzz/seeds/h1-request/post-cl.http
new file mode 100644
index 0000000..588d91a
Binary files /dev/null and b/fuzz/seeds/h1-request/post-cl.http differ
diff --git a/fuzz/seeds/h1-request/regress-refused-mid-dot.http b/fuzz/seeds/h1-request/regress-refused-mid-dot.http
new file mode 100644
index 0000000..4e7acd1
Binary files /dev/null and b/fuzz/seeds/h1-request/regress-refused-mid-dot.http differ
diff --git a/fuzz/seeds/h1-request/tight-limits.http b/fuzz/seeds/h1-request/tight-limits.http
new file mode 100644
index 0000000..5f39359
--- /dev/null
+++ b/fuzz/seeds/h1-request/tight-limits.http
@@ -0,0 +1,4 @@
+€GET / HTTP/1.1
+User-Agent: uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu
+X-Big: vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
+
diff --git a/fuzz/seeds/h1-request/upgrade-h2c.http b/fuzz/seeds/h1-request/upgrade-h2c.http
new file mode 100644
index 0000000..cbfd956
Binary files /dev/null and b/fuzz/seeds/h1-request/upgrade-h2c.http differ
diff --git a/fuzz/seeds/h1-request/upgrade-ws-nokey.http b/fuzz/seeds/h1-request/upgrade-ws-nokey.http
new file mode 100644
index 0000000..d5ad1e8
Binary files /dev/null and b/fuzz/seeds/h1-request/upgrade-ws-nokey.http differ
diff --git a/fuzz/seeds/h1-request/upgrade-ws.http b/fuzz/seeds/h1-request/upgrade-ws.http
new file mode 100644
index 0000000..d91a523
Binary files /dev/null and b/fuzz/seeds/h1-request/upgrade-ws.http differ
diff --git a/fuzz/seeds/h1-request/uri-args-many.http b/fuzz/seeds/h1-request/uri-args-many.http
new file mode 100644
index 0000000..b10b4b2
Binary files /dev/null and b/fuzz/seeds/h1-request/uri-args-many.http differ
diff --git a/fuzz/seeds/h1-request/uri-dotdot-deep.http b/fuzz/seeds/h1-request/uri-dotdot-deep.http
new file mode 100644
index 0000000..f332872
Binary files /dev/null and b/fuzz/seeds/h1-request/uri-dotdot-deep.http differ
diff --git a/fuzz/seeds/h1-request/uri-escapes.http b/fuzz/seeds/h1-request/uri-escapes.http
new file mode 100644
index 0000000..58c9e1b
Binary files /dev/null and b/fuzz/seeds/h1-request/uri-escapes.http differ
diff --git a/fuzz/seeds/h1-request/version-2.http b/fuzz/seeds/h1-request/version-2.http
new file mode 100644
index 0000000..84eda8c
Binary files /dev/null and b/fuzz/seeds/h1-request/version-2.http differ
diff --git a/fuzz/seeds/h1-response/bare-lf.http b/fuzz/seeds/h1-response/bare-lf.http
new file mode 100644
index 0000000..67b7a9a
Binary files /dev/null and b/fuzz/seeds/h1-response/bare-lf.http differ
diff --git a/fuzz/seeds/h1-response/chunked.http b/fuzz/seeds/h1-response/chunked.http
new file mode 100644
index 0000000..305c52e
Binary files /dev/null and b/fuzz/seeds/h1-response/chunked.http differ
diff --git a/fuzz/seeds/h1-response/http10.http b/fuzz/seeds/h1-response/http10.http
new file mode 100644
index 0000000..4aceee3
Binary files /dev/null and b/fuzz/seeds/h1-response/http10.http differ
diff --git a/fuzz/seeds/h1-response/interim.http b/fuzz/seeds/h1-response/interim.http
new file mode 100644
index 0000000..b31fe71
Binary files /dev/null and b/fuzz/seeds/h1-response/interim.http differ
diff --git a/fuzz/seeds/h1-response/odd-names.http b/fuzz/seeds/h1-response/odd-names.http
new file mode 100644
index 0000000..d910790
Binary files /dev/null and b/fuzz/seeds/h1-response/odd-names.http differ
diff --git a/fuzz/seeds/h1-response/ok.http b/fuzz/seeds/h1-response/ok.http
new file mode 100644
index 0000000..d3454d3
Binary files /dev/null and b/fuzz/seeds/h1-response/ok.http differ
diff --git a/fuzz/seeds/h1-response/set-cookies.http b/fuzz/seeds/h1-response/set-cookies.http
new file mode 100644
index 0000000..1525c79
Binary files /dev/null and b/fuzz/seeds/h1-response/set-cookies.http differ
diff --git a/fuzz/seeds/h1-response/switching.http b/fuzz/seeds/h1-response/switching.http
new file mode 100644
index 0000000..7e405f2
Binary files /dev/null and b/fuzz/seeds/h1-response/switching.http differ
diff --git a/fuzz/seeds/h1-response/tight-long.http b/fuzz/seeds/h1-response/tight-long.http
new file mode 100644
index 0000000..2ce408a
--- /dev/null
+++ b/fuzz/seeds/h1-response/tight-long.http
@@ -0,0 +1,3 @@
+€HTTP/1.1 200 OK
+X-Big: vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
+
diff --git a/fuzz/seeds/h1-response/unknown-headers.http b/fuzz/seeds/h1-response/unknown-headers.http
new file mode 100644
index 0000000..f3d8aa7
Binary files /dev/null and b/fuzz/seeds/h1-response/unknown-headers.http differ
|