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.
| Check | Expected | Observed | Verdict | Evidence |
|---|---|---|---|---|
| Harness exception for folded headers (D1) | Applies only when the input has a line feed followed by a space or tab | Applied to any ObsFold error in strict mode | As found: fail | pull request #2 |
| One-line parser changes (chunk overhead ratio, status-line guard) | At least one test fails for each | None of the tests noticed either | As found: fail | README, seeded bugs |
| Second parser to compare against | One, to show where “matches the C original” stops meaning “matches a production parser” | None | As found: gap | CHANGELOG, 0.2.0 |
| Fuzzing | The C SIMD path under the fuzzer | Two-minute runs on the portable C path only | As found: gap | CHANGELOG, 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
| Feature | Inputs attributed | Alone | Patterns (count) |
|---|---|---|---|
obs-fold | 258,293 | 116,039 | CCR 233016, PPR 25277 |
multi-space-start-line | 104,350 | 24,524 | CCC! 10136, CCR 85708, PPR 8477, RRC 29 |
version-digits | 404,441 | 219,612 | CCR 338185, PPR 66256 |
high-byte-start-line | 16,527 | 11,475 | CCC! 3406, CCR 10572, PPR 2431, RRC 118 |
bare-lf | 213 | 213 | PPR 213 |
bare-cr | 442 | 434 | PPR 358, RRP 84 |
leading-empty-lines | 1,492 | 1,467 | RRC 123, RRP 1369 |
header-line-without-colon | 1 | 1 | RRP 1 |
status-code-range | 6,495 | 6,353 | RRC 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.xwith 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
| Leg | What it runs | Executions | Result |
|---|---|---|---|
diff | C (ASan, portable path) against Rust; panics on a divergence | 3,538,824 | no crash, no divergence |
diff-sse42 | the same with the C side built with -msse4.2 | 5,881,336 | no crash, no divergence |
rust_only | Rust only, debug assertions, output invariants | 7,547,353 | no crash |
c_only | C only, ASan and UBSan, portable path | 5,731,452 | no crash, no sanitizer report |
c_only-sse42 | the same with -msse4.2 | 5,856,004 | no crash, no sanitizer report |
Table, 8 rows: Results
| Measure | Value | Source |
|---|---|---|
| Tests | 39 (10 known-answer, 1 random invariant, 1 harness unit, 12 corpus replay, 5 property, 5 httparse class, 3 targeted, 2 doc), plus 1 mutant test | re-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 bugs | 5 of 5 detected: generator after 3, 286, 8 and 10,085 inputs; one only by a targeted case; 500,000-input control clean | re-run for this page |
| Three-way sweep | 2,000,000 inputs: 0 differences between picohttpparser and fuzzdiff; 609,278 disagree with httparse, all explained by documented classes | docs/DIFFERENTIAL_HTTPARSE.md |
| Corpus | 204 inputs: 94 from picohttpparser's own tests, 110 hand-written (plus 6 benchmark inputs) | corpus/; README |
| Speed, head parsing | Rust 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 decoding | level (0.99x and 1.01x) | docs/BENCHMARKS.md |
| Bugs found in the C original | none | docs/FINDINGS.md |
Technical detailsHow 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.
Describe this diagram
- Input bytes. corpus, generator, proptest, libFuzzer.
- Decode into a case. entry point, header capacity, last_len, chunk splits.
- C original and Rust port, same case. C: exact-size heap copy, ASan and UBSan. Rust: safe.
- compare(). consumed length, fields, decoded bytes.
- Equal, BothReject, RustStricter (D1, D2). expected outcomes.
- 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.