parse-net 0.9.3

Grammar for Internet message fields: RFC 9110 field syntax, media types, RFC 9651 Structured Fields, HTTP and RFC 5322 dates, negotiation, entity tags, ranges, Content-Disposition, Cache-Control, Forwarded, auth challenges, and the email lexical layer. no_std, no allocation, sans-IO, zero dependencies.
Documentation
# parse-net v0.9.0 - Beta

**Date:** 2026-11-03
**Compare:** [`v0.8.0...v0.9.0`](https://github.com/jamesgober/parse-net/compare/v0.8.0...v0.9.0)

**The pre-1.0 polish pass.** Three independent reviews read the whole crate against the RFCs and proved each finding by running it. They found 46 defects that the fuzz targets and property tests had not, and every one is fixed, with a regression test. A fresh performance pass brought every compared row to at least 30% faster than its competitor. `0.9.0` is the first release of the no-breaking-change cycle that leads to the release candidates.

**This release changes behaviour.** It refuses some inputs that `0.8.0` accepted and accepts some it refused. The table under Compatibility lists what a consumer may notice.

## Security

- **Forwarded hop forgery.** `write_element` wrote a host without quotes unless it had a port or was IPv6, but a reg-name may hold `,`, `;`, and `=`. A `Host` of `a.example,for=203.0.113.66;x=y` produced a second element, and the next proxy's `client_address` returned the forged address. Every host that is not a token is now quoted, and a property test proves no host can forge a pair.
- **Writers carried what no parser produces.** `Host`, `HostPort`, and `Origin` have public fields, and their writers copied them unchecked, so `Host::Name(b"a\r\nX-Evil: 1")` wrote the line break. They now refuse any byte no parsed value holds.
- **Percent-encoded IPv4 hosts.** `127.0.0.%31` and `127%2e1` passed as names, while a browser's URL parser reads them as `127.0.0.1`: an SSRF filter bypass, the disagreement D-040 exists to remove. The rule now reads decoded bytes.
- **`decode_words`.** It copied raw CRLF and ESC into its output, and each failed `=?` candidate scanned to the end of the value, so 1 MiB took 62 seconds. It is linear now: 11 ms. Controls in its text are refused.
- **RFC 2231.** C1 controls passed through continuations, and a section given twice gave a value that depended on the order of the parameters.
- **Cache-Control.** `max-stale=abc` read as a bare `max-stale`, "any staleness". It now reads as no directive, with `invalid_values` set.
- **`Debug`.** A bare `Authorization: sk_live_x` printed through the scheme, and `OwnedMediaType` printed parameter values as bytes. Neither does now.
- `check_header_value` and `fold` refuse every control but HTAB, and `pct::encode` always encodes C0 and DEL.

## Added

- `range::parse_if_range_at`, for all three date forms in `If-Range`. Without a clock, `parse_if_range` reads IMF-fixdate and asctime (D-035).
- `forwarded::Proto::{Ws, Wss, Other}`: `proto` takes any URI scheme. `proto=wss` from a WebSocket proxy used to fail the whole chain closed (D-077).
- `dev/READINESS-1.0.md`: what stands between parse-net and 1.0, for James's decision.

## Performance

Every compared row is at least 30% faster than the best Rust competitor, 31% to 76%. The narrowest two at 0.8 moved most:

| Operation | 0.8 | 0.9 | Instructions |
|---|---|---|---|
| `byte_ranges` | 21% faster than `headers` | 76% | 815 to 197 |
| `parse_content_disposition` | 24% faster than `mailparse` | 42% | 4,014 to 3,585 |
| `parse_media_type` and `charset` | 30% faster than `mime` | 41% | 797 to 729 |
| `client_address` | no competitor | no competitor | 3,193 to 1,289 |

The benchmarks ran on a machine other builds were using, so the absolute times in `docs/PERFORMANCE.md` are about 30% high on both sides of every row; the percentages and instruction counts are not affected.

## Compatibility

No API was removed. MSRV 1.85, edition 2024, `no_std` without `alloc`, zero runtime dependencies. Behaviour changed as follows.

| Input or call | 0.8 | 0.9 |
|---|---|---|
| `Range` numerals past `u64::MAX` | `NumberOverflow` | saturate (RFC 9110 section 14.1.2) |
| `bytes=-1` on an empty representation | `Unsatisfiable` | `Ignore` |
| `*,` in `If-Match` or `If-None-Match` | `Any` | `Malformed` |
| `Accept-Language: en_US`, `gzip;foo=bar` through `Negotiator` | matched | malformed: ignored by default, `Refused` under `Refuse` |
| a control after a backslash in a quoted string or comment | `InvalidQuotedPair` | its control kind |
| `decode_words` on raw controls | copied | refused |
| `check_header_value`, `fold` on controls other than CR, LF, NUL | accepted | refused |
| `Proto` | two variants | five; it is `#[non_exhaustive]` |
| `parse_if_range` on an asctime date | `InvalidDate` | read |
| an RFC 850 date in `If-Range` | `InvalidDate` | read by `parse_if_range_at`; `parse_if_range` has no clock, so it stays `InvalidDate` |

Error offsets moved in several places, always toward the first byte not accepted. A caller that matches on `ErrorKind` should look at the table; one that only logs need not.

## Testing

- `tests/review_core.rs`, `tests/review_security.rs`, and `tests/review_mail.rs` hold a regression for each finding.
- Property tests compare the one-pass `client_address`, `xff_member`, and the `byte_ranges` path with the code they replaced, on results and on errors.
- `tests/complexity.rs` gains the adversarial `decode_words` shapes. Timing is checked on an optimised build, in a CI job, at 2.0 of the size ratio (D-073).
- All 34 fuzz targets ran for 24 hours at commit `5d73db8` with no finding. Three of them, `accept_negotiate`, `http_date`, and `sfv_list`, ran in two pieces on the same binary and corpus: WSL steps its wall clock back about 1.8 seconds every 30 seconds, and libFuzzer's unit timer reads a step inside a unit as a timeout of -1 seconds. Each stopping input replayed clean in milliseconds before the target went on. The weekly workflow for the differential targets now runs.

## What's next

`0.9.x`: ws-net, smtp-net, and web-net on parse-net, then the release candidates. `1.0.0` is James's decision; `dev/READINESS-1.0.md` lists what stands open.

**Full Changelog:** https://github.com/jamesgober/parse-net/compare/v0.8.0...v0.9.0