fuzzdiff

What it does
picohttpparser is an HTTP parser written in C. It was ported to safe Rust, then the C original, the port and a third parser (httparse) were run on the same input.
Why it matters
A rewrite can change behavior without anyone noticing. Running the original next to the copy shows where.
What was found
5 bugs were planted to see whether the checks would notice. The 0.1.0 tests missed 2; 0.2.0 catches all 5. Against the C original there were 0 differences in 2,000,000 inputs. No vulnerability was found.

See it Read the limits

The harness detected all five deliberately seeded bugs. Two of them (added in 0.2.0 after a mutation sweep) were missed by the 0.1.0 tests, and one of the five is caught only by a hand-built case, not by the random generator.

5 of 5 detected run 43037960324 passed 2026-11-11

  • detected
  • not detected by the 0.1.0 tests

3 of 5 detected; 2 survived the 0.1.0 tests

5 of 5 detected; 4 by the generator, 1 only by a targeted case

See it

Pick an input and compare what the C parser, the Rust port and httparse each return.

Plate 1

Side-by-side parser

Recorded, not live. All 19 inputs are listed here; with JavaScript on, a picker shows one at a time.

What each parser returned for 19 inputs, and how the harness classified the C and Rust results
Input (bytes with escapes)picohttpparser (C, dd573d8)fuzzdiff (Rust)httparse 1.10.1Harness classification and httparse comparison
Request line
Baseline request
GET / HTTP/1.1\r\nHost: a\r\n\r\n
complete (consumed 27 bytes)
method "GET", target "/", HTTP/1.1
1 header
Host: a
complete (consumed 27 bytes)
method "GET", target "/", HTTP/1.1
1 header
Host: a
complete (consumed 27 bytes)
method "GET", target "/", HTTP/1.1
1 header
Host: a
Equal

C and Rust returned the same result.

Pattern CCC (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Accepted by all three (context) (RFC 9112 6.1, 6.2, 6.3). The three parsers behave alike here.

Request line
HTTP/1.0 request (corpus)
GET / HTTP/1.0\r\n\r\n
complete (consumed 18 bytes)
method "GET", target "/", HTTP/1.0
0 headers
complete (consumed 18 bytes)
method "GET", target "/", HTTP/1.0
0 headers
complete (consumed 18 bytes)
method "GET", target "/", HTTP/1.0
0 headers
Equal

C and Rust returned the same result.

Pattern CCC (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). All three agree.

Request line
Two SP after the method
GET / HTTP/1.1\r\n\r\n
complete (consumed 19 bytes)
method "GET", target "/", HTTP/1.1
0 headers
complete (consumed 19 bytes)
method "GET", target "/", HTTP/1.1
0 headers
error (Token)Equal

C and Rust returned the same result.

Pattern CCR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Number of SP between start-line elements (RFC 9112 3, 4). Stricter: httparse.

Request line
HTTP/1.2
GET / HTTP/1.2\r\n\r\n
complete (consumed 18 bytes)
method "GET", target "/", HTTP/1.2
0 headers
complete (consumed 18 bytes)
method "GET", target "/", HTTP/1.2
0 headers
error (Version)Equal

C and Rust returned the same result.

Pattern CCR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: HTTP version digits (RFC 9112 2.3). Stricter: httparse.

Request line
0x80 in the target
GET /\x80 HTTP/1.1\r\n\r\n
complete (consumed 19 bytes)
method "GET", target "/\x80", HTTP/1.1
0 headers
complete (consumed 19 bytes)
method "GET", target "/\x80", HTTP/1.1
0 headers
error (Token)Equal

C and Rust returned the same result.

Pattern CCR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Octets 0x80-0xFF in the request-target (RFC 9112 3.2, RFC 9110 4.1). Stricter: httparse.

Request line
NUL byte in the target (corpus)
GET /a\x00b HTTP/1.1\r\n\r\n
errorerror (Control)error (Token)BothReject

Both rejected the input.

Pattern RRR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). All three agree.

Request line
Two empty lines before the request
\r\n\r\nGET / HTTP/1.1\r\n\r\n
errorerror (Token)complete (consumed 22 bytes)
method "GET", target "/", HTTP/1.1
0 headers
BothReject

Both rejected the input.

Pattern RRC (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Empty lines before the start-line (RFC 9112 2.2). Stricter: picohttpparser.

Headers
Obsolete line folding
GET / HTTP/1.1\r\nA: b\r\n c\r\n\r\n
complete (consumed 28 bytes)
method "GET", target "/", HTTP/1.1
2 headers
A: b
(no name): c
complete (consumed 28 bytes)
method "GET", target "/", HTTP/1.1
2 headers
A: b
(no name): c
error (HeaderName)Equal

C and Rust returned the same result.

Pattern CCR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Obsolete line folding (RFC 9112 5.2). Stricter: httparse.

Headers
Obsolete line folding, strict mode (D1)
GET / HTTP/1.1\r\nA: b\r\n c\r\n\r\n
complete (consumed 28 bytes)
method "GET", target "/", HTTP/1.1
2 headers
A: b
(no name): c
error (ObsFold)error (HeaderName)RustStricter (D1)

Rust rejects what C accepts, by a documented deviation (D1: optional rejection of folded header lines, switched on for this input).

Pattern CCR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Obsolete line folding (RFC 9112 5.2). Stricter: httparse. The pattern is for the default configuration; this input runs Rust in strict mode.

Headers
Several folded lines (corpus)
GET / HTTP/1.1\r\nHost: a\r\n b\r\n\tc\r\nX: y\r\n\r\n
complete (consumed 41 bytes)
method "GET", target "/", HTTP/1.1
4 headers
Host: a
(no name): b
(no name): \tc
X: y
complete (consumed 41 bytes)
method "GET", target "/", HTTP/1.1
4 headers
Host: a
(no name): b
(no name): \tc
X: y
error (HeaderName)Equal

C and Rust returned the same result.

Pattern CCR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Explained by features: obs-fold.

Headers
100 header lines, 64 slots (corpus)
GET / HTTP/1.1\r\nH0: v0\r\nH1: v1\r\nH2: v2\r\nH3: v3\r\nH4: v4\r\nH5: v5\r\nH6: v6\r\nH7: v7\r\nH8: v8\r\nH9: v9\r\nH10: v10\r\nH11: v11\r\nH12: v12\r\nH13: v13\r\nH14: v14\r\nH15: v15\r\nH16: v16\r\nH17: v17\r\nH18: v18\r\nH19: v19\r\nH2…
First 240 characters of the escaped bytes shown; 998 bytes in total.
errorerror (TooManyHeaders)error (TooManyHeaders)BothReject

Both rejected the input.

Pattern RRR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). All three agree.

Response
Two SP after the status code
HTTP/1.1 200 OK\r\n\r\n
complete (consumed 20 bytes)
HTTP/1.1, status 200, reason "OK"
0 headers
complete (consumed 20 bytes)
HTTP/1.1, status 200, reason "OK"
0 headers
complete (consumed 20 bytes)
HTTP/1.1, status 200, reason " OK"
0 headers
Equal

C and Rust returned the same result.

Pattern CCC! (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Number of SP between start-line elements (RFC 9112 3, 4). Stricter: same verdict, fields differ.

Chunked body
Bare LF inside a chunk extension
5;a=b\nhello\r\n0\r\n\r\n
errorerror (ChunkLineEnding)complete (size line 13 bytes, size 5)BothReject

Both rejected the input.

Pattern RRC (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Chunk extensions (RFC 9112 7.1.1). Stricter: picohttpparser.

Chunked body
Empty chunk size
\r\nhello\r\n0\r\n\r\n
errorerror (ChunkSize)complete (size line 2 bytes, size 0)BothReject

Both rejected the input.

Pattern RRC (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Chunk-size line shape (RFC 9112 7.1). Stricter: picohttpparser.

Chunked body
17 hex digits in a chunk size
10000000000000000\r\nhello
errorerror (ChunkSizeOverflow)error (InvalidChunkSize)BothReject

Both rejected the input.

Pattern RRR (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Chunk-size line shape (RFC 9112 7.1). The three parsers behave alike here.

Truncated input
Version cut after HTTP/1.
GET / HTTP/1.
partial (needs more bytes)partial (needs more bytes)partial (needs more bytes)Equal

C and Rust returned the same result.

Pattern PPP (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). Documented class: Partial input: when a parser gives up (none (implementation behaviour)). The three parsers behave alike here.

Truncated input
Request cut after the target start (corpus)
GET /
partial (needs more bytes)partial (needs more bytes)partial (needs more bytes)Equal

C and Rust returned the same result.

Pattern PPP (picohttpparser, fuzzdiff, httparse; C complete, P partial, R reject; ! fields differ). All three agree.

Planted bug
Seeded bug HeaderLenOffByOne
GET / HTTP/1.1\r\nX-Long: 0123456789abcdefghij\r\n\r\n
complete (consumed 48 bytes)
method "GET", target "/", HTTP/1.1
1 header
X-Long: 0123456789abcdefghij
complete (consumed 48 bytes)
method "GET", target "/", HTTP/1.1
1 header
X-Long: 0123456789abcdefghi
complete (consumed 48 bytes)
method "GET", target "/", HTTP/1.1
1 header
X-Long: 0123456789abcdefghij
Divergence (ResultMismatch)

The two results differ, so the harness reports a bug. Here the bug is the seeded one named in the title.

Shown for reference only. The seeded bug is in the Rust column, so no pattern is classified.

Planted bug
Seeded bug MissingBoundsCheck
GET / HTTP/1.1
partial (needs more bytes)panic (the parser panicked)partial (needs more bytes)Divergence (RustPanic)

The two results differ, so the harness reports a bug. Here the bug is the seeded one named in the title.

Shown for reference only. The seeded bug is in the Rust column, so no pattern is classified.

Seeded bugs: how much input the harness needed

Measured detection effort for the five seeded bugs (the mutant test; deterministic generator, seed = input index + 1)
Seeded bugGenerator inputs until caughtGenerator timeTargeted caseHow it showed
HeaderLenOffByOne

A header value longer than 16 bytes comes back one byte short.

339 µsnone catches itResultMismatch
MissingBoundsCheck

The end-of-buffer guard after HTTP/1.x is missing, so a head cut right after the version reads one byte too far (a panic in Rust).

2861.0 msnone catches itRustPanic
WrongStateAfterCrlf

After a trailer line the chunked decoder goes to the wrong state and never sees the blank line that ends the trailers.

855 µsnone catches itResultMismatch
OverheadRatio

The framing-overhead guard fires at 80% instead of 75%; it needs more than 100 KiB of chunk framing to matter.

not caught in 2,000,0006.14 scase 1ResultMismatch
StatusGuard

The response parser waits for 3 bytes after the status-line spaces instead of 4.

10,08530.7 mscase 4ResultMismatch
Control (no bug seeded)500,000 inputs1.55 s9 targeted cases0 divergences
  • half-decades of generated inputs until the harness caught the bug
  • budget not needed
  • half-decades searched without catching it

caught after 3 inputs

caught after 286 inputs

caught after 8 inputs

not caught in 2,000,000 inputs; caught by targeted case 1

caught after 10,085 inputs

Each tick is a factor of about 3.2 in generated inputs (half a decade), so a short strip means little input was needed; the counts in the table are exact. The budget is 2,000,000 inputs (13 ticks). Times are one run on the capture machine and vary; the counts do not. The three bugs caught in under 300 inputs show the plumbing works; they do not show that every subtle bug would be found.

With default settings picohttpparser and fuzzdiff return the same result on every input here, except the two seeded-bug rows and the strict-mode row (documented deviation D1); httparse, a different parser, differs from both in a fixed set of documented classes. Recorded, not live: every result was returned by the parser named, on the bytes shown (fuzzdiff 0.2.0, tag v0.2.0, commit 6ec1e6e; picohttpparser dd573d8; httparse 1.10.1 (default configuration); 64 header slots for all three; Windows, release build, MSVC build of the C code), run 2026-11-11 with scripts/capture_fuzzdiff_bench.py. The page runs no parser. The httparse column covers head parsing and, for chunked bodies, only the first chunk-size line. The two seeded-bug rows run the Rust parser with a test-only bug switched on; the shipped parser has none.
Checked against
The C original and httparse, same bytes
Found, then left
As found 2 of 5 seeded bugs not noticed by the 0.1.0 tests; harness accepted any folded-header error as expected (PR #2)
As left 5 of 5 detected; 0 differences from C in 2,000,000 inputs; 609,278 disagree with httparse, all in documented classes
Limits
Smoke fuzzing (120 s per leg); Miri on the pure-Rust crate only; no vulnerability found Full limits
Technical description and verification record: instrument, reference standard, date, how to reproduce (self-issued, not accredited)

picohttpparser, an HTTP/1.x parser written in C, ported to safe Rust and checked by running the C original, the Rust port and a second parser (httparse) on the same input and reporting every difference.

Instrument
fuzzdiff v0.2.0 (tag, commit 6ec1e6e, released 2026-11-11)
Reference standard
the C original, picohttpparser commit dd573d8, run on the same bytes; httparse 1.10.1 as a second parser for the head-parsing comparison
Measured on
2026-11-11, Windows, rustc 1.99.1; Miri, sanitizers and libFuzzer runs in CI on Linux
Issued by
The author. Self-issued verification record, not accredited.
Reproduce
cargo test --all
cargo test --release -p fuzzdiff-harness --features mutants --test mutants -- --nocapture

As foundTwo seeded bugs no test noticed, and a harness exception that would have hidden one

Version 0.1.0 compared the Rust port with one reference, the C original, and four things were weak. The harness exception for folded headers (D1) applied to any Rust ObsFold error in strict mode, so a bug that reported ObsFold on a normal header would have passed as expected (pull request #2 narrowed it: the input must contain a line feed followed by a space or tab, otherwise it is a divergence). A mutation sweep of the three seeded bugs then found two one-line changes to the parser that no test noticed. The libFuzzer runs were two minutes per target, with the C SIMD path never reached. And “matches picohttpparser” said nothing about any other parser.

Differential testing runs two implementations on the same input and flags any difference. It needs a rule for which differences are expected, and a second opinion to show what “same as the original” does and does not mean.
Reading ledger, version 0.1.0
CheckExpectedObservedVerdictEvidence
Harness exception for folded headers (D1)Applies only when the input has a line feed followed by a space or tabApplied to any ObsFold error in strict modeAs found: failpull request #2
One-line parser changes (chunk overhead ratio, status-line guard)At least one test fails for eachNone of the tests noticed eitherAs found: failREADME, seeded bugs
Second parser to compare againstOne, to show where “matches the C original” stops meaning “matches a production parser”NoneAs found: gapCHANGELOG, 0.2.0
FuzzingThe C SIMD path under the fuzzerTwo-minute runs on the portable C path onlyAs found: gapCHANGELOG, 0.2.0

As leftFive seeded bugs detected, and a second parser to compare against

Version 0.2.0, released 2026-11-11 (tag commit 6ec1e6e). The numbers below were re-run on 2026-11-11 from a fresh clone (Windows, rustc 1.99.1, MSVC build of the C code) and compared with the README and the CI logs. Miri, the sanitizer builds and the libFuzzer runs happen only in CI (Linux), so their numbers come from the CI logs.

Tests and seeded bugs, re-run locally

  • detected
  • not detected by the 0.1.0 tests

3 of 5 detected; 2 survived the 0.1.0 tests

5 of 5 detected; 4 by the generator, 1 only by a targeted case

Output, 26 lines: $ cargo test --all; cargo test ... mutants; cargo llvm-cov
$ cargo test --all
...tests\random_smoke.rs     test result: ok.  1 passed
...tests\vectors.rs          test result: ok. 10 passed
...harness unit test         test result: ok.  1 passed
...tests\corpus.rs           test result: ok. 12 passed
...tests\httparse_classes.rs test result: ok.  5 passed
...tests\props.rs            test result: ok.  5 passed
...tests\targeted.rs         test result: ok.  3 passed
...doc-tests                 test result: ok.  2 passed       # 39 in all

$ cargo test --release -p fuzzdiff-harness --features mutants --test mutants -- --nocapture
HeaderLenOffByOne: generator caught after 3 inputs in 68.2µs as ResultMismatch
MissingBoundsCheck: generator caught after 286 inputs in 1.1411ms as RustPanic
WrongStateAfterCrlf: generator caught after 8 inputs in 37.5µs as ResultMismatch
OverheadRatio: generator did not catch it in 2000000 inputs (6.0865621s)
OverheadRatio: targeted case #1 catches it as ResultMismatch
StatusGuard: generator caught after 10085 inputs in 30.3468ms as ResultMismatch
StatusGuard: targeted case #4 catches it as ResultMismatch
control (no mutant): 500000 inputs and 9 targeted cases, no divergence, 1.5399282s
test result: ok. 1 passed

$ cargo llvm-cov --workspace --summary-only      # src/ files only
chunked.rs   145 lines   2 missed
error.rs      22 lines   0 missed
message.rs   322 lines  20 missed
mutants.rs     3 lines   0 missed           # 492 lines, 22 missed: 95.53%

Trimmed: per-test lines and timing removed, the failing-input lines the mutant test also prints are omitted, and the coverage files are summarized by line counts (the percentage is computed from the four rows and equals the CI coverage job). Timings vary from run to run; the counts do not.

Three-way comparison with httparse

httparse 1.10.1 (pinned exactly) is the HTTP/1.x parser hyper builds on. It was added as a third parser so that “matches the C original” can be compared with “matches a production Rust parser”. The result is a map of leniency choices, not a list of bugs: each class is pinned by a test, and a new class fails CI. Numbers are from docs/DIFFERENTIAL_HTTPARSE.md; the table is copied from its generated block.

Sweep of 2,000,000 deterministic inputs (999,999 request, 500,001 response, 500,000 header block; half from the corpus, half from the well-formed generators, two thirds then mutated). All three parsers did the same thing on 1,390,722 (69.54%) and disagreed on 609,278. picohttpparser and fuzzdiff differed on 0; disagreements that no documented class explains: 0; 36,905 are only httparse rejecting an invalid prefix earlier than the other two. The share is large because the mutator produces folds, doubled spaces and version digits constantly, not because the parsers disagree on typical traffic.

Table, 9 rows: Disagreements by attributed feature, as in docs/DIFFERENTIAL_HTTPARSE.md. Patterns are picohttpparser, fuzzdiff, httparse: C complete, P partial, R reject, ! all complete but fields differ
Disagreements by attributed feature, as in docs/DIFFERENTIAL_HTTPARSE.md. Patterns are picohttpparser, fuzzdiff, httparse: C complete, P partial, R reject, ! all complete but fields differ
FeatureInputs attributedAlonePatterns (count)
obs-fold258,293116,039CCR 233016, PPR 25277
multi-space-start-line104,35024,524CCC! 10136, CCR 85708, PPR 8477, RRC 29
version-digits404,441219,612CCR 338185, PPR 66256
high-byte-start-line16,52711,475CCC! 3406, CCR 10572, PPR 2431, RRC 118
bare-lf213213PPR 213
bare-cr442434PPR 358, RRP 84
leading-empty-lines1,4921,467RRC 123, RRP 1369
header-line-without-colon11RRP 1
status-code-range6,4956,353RRC 6065, RRP 430

In words, as the repository states them:

  • httparse is stricter on obsolete line folding (RFC 9112 section 5.2), more than one SP between request-line elements, HTTP/1.x with x other than 0 or 1, and octets 0x80 to 0xFF in the request-target.
  • picohttpparser, and so fuzzdiff by default, is stricter on more than one empty line before the request-line, a response status code outside 100 to 599, bare LF inside a chunk extension, and an empty chunk size.
  • Same verdict, different fields: extra SP before a reason phrase, and obs-text in a reason phrase.
  • Different cut-off on partial input: httparse rejects an already-invalid prefix before picohttpparser says it needs more bytes.
  • All three agree, for example, on rejecting whitespace before the colon, bare CR and NUL, and on accepting 0x80 in a field value.

Miri and libFuzzer, from CI

Miri ran on the pure-Rust crate only: 13 tests (10 known-answer, 1 pseudo-random invariant, 2 doc tests), no undefined behaviour reported, in the ci run run 43037960324 passed 2026-11-11. The fuzz workflow ran five legs for 120 s each on nightly, seeded from the corpus (612 seed files per leg, cold cache), including the C SIMD path (-msse4.2). Counts are libFuzzer's own “Done N runs” lines in the log of run 43035685955 passed 2026-11-11.

Table, 5 rows: libFuzzer results
libFuzzer results
LegWhat it runsExecutionsResult
diffC (ASan, portable path) against Rust; panics on a divergence3,538,824no crash, no divergence
diff-sse42the same with the C side built with -msse4.25,881,336no crash, no divergence
rust_onlyRust only, debug assertions, output invariants7,547,353no crash
c_onlyC only, ASan and UBSan, portable path5,731,452no crash, no sanitizer report
c_only-sse42the same with -msse4.25,856,004no crash, no sanitizer report
Table, 8 rows: Results
Results
MeasureValueSource
Tests39 (10 known-answer, 1 random invariant, 1 harness unit, 12 corpus replay, 5 property, 5 httparse class, 3 targeted, 2 doc), plus 1 mutant testre-run for this page; README
Line coverage of src/95.53%re-run for this page (cargo-llvm-cov); same figure in the CI coverage job
Seeded bugs5 of 5 detected: generator after 3, 286, 8 and 10,085 inputs; one only by a targeted case; 500,000-input control cleanre-run for this page
Three-way sweep2,000,000 inputs: 0 differences between picohttpparser and fuzzdiff; 609,278 disagree with httparse, all explained by documented classesdocs/DIFFERENTIAL_HTTPARSE.md
Corpus204 inputs: 94 from picohttpparser's own tests, 110 hand-written (plus 6 benchmark inputs)corpus/; README
Speed, head parsingRust about 1.4x to 1.5x slower than C (82 B GET: 59.4 ns vs 42.3 ns)docs/BENCHMARKS.md (Ryzen 5 5600G, one run, not re-run)
Speed, chunked decodinglevel (0.99x and 1.01x)docs/BENCHMARKS.md
Bugs found in the C originalnonedocs/FINDINGS.md
Technical details

How it works

design, implementation points and diagram

The problem

Network parsers written in C keep having the same bugs: reads past the end of a buffer, length arithmetic that wraps, off-by-ones, half-initialized output. Rewriting one in a memory-safe language is a fair answer only if the rewrite behaves like the original.

picohttpparser is a good test subject: 715 lines of C, MIT or Perl licensed, hostile-input facing, and full of the pointer idioms that cause those bugs. The project asks whether a port can be shown to behave identically, instead of just saying so, and then what “identically” leaves out.

What it does

The Rust crate has #![forbid(unsafe_code)], no dependencies and no allocation, and covers request heads, response heads, header blocks and an incremental in-place chunked decoder. The vendored C (commit dd573d8, unmodified, hashes checked) is built next to it. A harness decodes fuzzer bytes into a case (which entry point, header capacity, last_len, how a chunked body is split across calls), runs both, and classifies the result as Equal, BothReject, RustStricter (one of two documented deviations) or Divergence. A divergence fails the tests and the CI fuzz job. Since 0.2.0 an oracle runs the same head through httparse as well and sorts every disagreement into a documented class.

Plate 2fuzzdiff differential harnessFuzzer or test bytes are decoded into a case. The same case runs through the C original, which is compiled with sanitizers and fed exact-size heap copies, and through the safe Rust port. The harness compares the two results and classifies them as Equal, BothReject, RustStricter or Divergence.Input bytescorpus, generator, proptest,libFuzzerADecode into a caseentry point, header capacity,last_len, chunk splitsBC original and Rust port, samecaseC: exact-size heap copy, ASan andUBSan. Rust: safeCcompare()consumed length, fields, decodedbytesDEqual, BothReject,RustStricter (D1, D2)expected outcomesEDivergencea bug in one side; fails thetests and the CI fuzz jobF
Fuzzer or test bytes are decoded into a case. The same case runs through the C original, which is compiled with sanitizers and fed exact-size heap copies, and through the safe Rust port. The harness compares the two results and classifies them as Equal, BothReject, RustStricter or Divergence.
Describe this diagram
  1. Input bytes. corpus, generator, proptest, libFuzzer.
  2. Decode into a case. entry point, header capacity, last_len, chunk splits.
  3. C original and Rust port, same case. C: exact-size heap copy, ASan and UBSan. Rust: safe.
  4. compare(). consumed length, fields, decoded bytes.
  5. Equal, BothReject, RustStricter (D1, D2). expected outcomes.
  6. Divergence. a bug in one side; fails the tests and the CI fuzz job.

Four implementation points

A chunked decoder with no pointer arithmetic

The C decoder walks the buffer with pointers. The Rust one (src/chunked.rs) is an eight-state machine (Size, Ext, HeaderLf, Data, DataCr, DataLf, TrailerHead, TrailerMiddle) over two indices into the slice. Every read is buf.get(src), so running out of input is a break out of the loop, not an out-of-bounds read, and body bytes are moved to the front with copy_within(src..src + take, dst) where take is capped by what has arrived and what the chunk still needs. The hex-digit cap (usize::BITS / 4) and the framing-overhead ratio check after 102,400 bytes are kept, because the digit cap is what stops a long size line from wrapping, the class behind CVE-2013-2028 in nginx.

The request line has to fail in C's order

C reports “need more data” (-2) or “error” (-1) depending on which check it reaches first, so a port that validates in a different order disagrees on truncated input. request() follows the C sequence: skip a stray leading CRLF, read the method token, skip spaces, read the target, skip spaces, only then reject an empty method or path, then the version. Missing data anywhere propagates as Partial through ?. The corpus replay tests every prefix length of every input with an incremental last_len to hold this down.

The seeded bugs vanish when the feature is off

To show the harness can see bugs, src/mutants.rs adds five deliberate ones behind a mutants feature: a header value one byte short, a missing end-of-buffer guard, the wrong chunked state after a trailer line, the chunk overhead guard at 80% instead of 75%, and a status-line guard that waits for 3 bytes instead of 4. Without the feature the check is const fn active(..) -> bool { false }, so the shipped parser contains none of it. With the feature on, the state lives in a thread-local, so tests can switch bugs on one at a time. The last two were added after a mutation sweep found them undetected.

Exact-size copies so the sanitizer can see over-reads

A C over-read that lands inside a larger allocation goes unnoticed. The C shim (c/shim.c) copies each input into a heap block of exactly len bytes (dup_exact), so one byte past the end is outside the allocation and AddressSanitizer reports it. The shim also returns offsets instead of pointers so the harness can compare results by value.

Tolerance and limitsWhat the numbers do not show

  • The fuzz runs are short. Two minutes per leg is a smoke run, not assurance, and the libFuzzer targets never reach the overhead guard or the last_len case (D2). No 32-bit or big-endian target has been run.
  • No new vulnerability was found. picohttpparser has no published security advisory, the httparse differences are leniency choices and not defects in the C code, and the advisories in CVE_CLASSES.md are examples of bug classes, mostly from other parsers.
  • Miri covers the pure-Rust crate only. The C code and the FFI harness cannot run under it, so the comparison with C is not covered by Miri. The crate forbids unsafe code, so there is little for it to find.
  • The httparse comparison is narrow. It covers head parsing and the first chunk-size line only (httparse has no chunked decoder), httparse in its default configuration, two parsers on one input and not a proxy and server pair, and 64 header slots on each side.
  • Five seeded bugs are a small sample. Three are caught in under 300 generated inputs, which shows the plumbing works; one is caught only by a hand-built case because the random generator never builds that input.
  • The Rust port is slower on head parsing (about 1.4x to 1.5x), and the cause was not profiled. On Windows the C side ran its portable path, so a Linux build with SIMD may widen the gap; that was not measured.
  • Two deliberate deviations. D1: optional rejection of folded header lines, off by default. D2: last_len larger than the buffer is an error in Rust, while C reads out of bounds. D2 was found by reading the code and has not been reproduced under a sanitizer. Details in DEVIATIONS.md.
  • Blind generator. The 27.6 million-input seeded generator run in the README has no coverage feedback and is a stand-in for fuzzing, not a replacement.
Evidence

Where each number comes from

8 links

Chips show the state of each GitHub Actions run as captured when this page was built. The page does not call GitHub at runtime.

Tests, sanitizers, Miri, coverage (ci on main)
run 43037960324 passed 2026-11-11
Fuzz run with the counts below (pull request 3)
run 43035685955 passed 2026-11-11
Last fuzz run of pull request 3
run 43037421227 passed 2026-11-11
Release
v0.2.0
Harness fix
pull request #2
Three-way comparison
docs/DIFFERENTIAL_HTTPARSE.md

CI evidence

Workflow runs behind the numbers on this page
WorkflowWhat it coversRun
ci on main, v0.2.0 commit 6ec1e6e7 jobs: tests on Ubuntu and Windows; the C code under ASan and UBSan in a portable and an SSE4.2 variant; coverage with a 90% line floor; Miri on the Rust crate; cargo-denyrun 43037960324 passed 2026-11-11
fuzz, pull request 3 (commit 99cddc4)the five libFuzzer legs, 120 s each; the counts aboverun 43035685955 passed 2026-11-11
fuzz, last run of pull request 3 (commit 41b8843)the same legs after later pushes; green, counts differ from run to runrun 43037421227 passed 2026-11-11

The fuzz workflow runs on pull requests, weekly and on request, not on every push to main, so the newest fuzz run is the one for the pull request that became 0.2.0. Bench data and the program that produced it: assets/data/fuzzdiff-bench.json, scripts/capture_fuzzdiff_bench.py, scripts/test_bench_fuzzdiff.mjs.