# http-net v0.3.0 - Core
**Date:** 2026-10-21
**Compare:** `v0.2.0...v0.3.0`
**The design lock revisited, a credential leak closed, and the core
completed.** v0.3.0 starts by writing down the whole 1.0 surface:
`docs/API.md` now describes every public item the crate will have at 1.0,
each marked with the version it ships in, and it was approved before any
0.3 code changed. The release then fixes what 0.2.0 got wrong (its `Debug`
output printed cookies and bearer tokens), refuses the deprecated `h2c`
upgrade and switches to protocols the client never offered, tightens `Host`
validation, fills in the method and status registries, and adds the
evidence the standard asks for: cargo-fuzz targets, an allocation-counting
test, a differential test against `httparse`, and an RFC conformance
table. There are still no dependencies.
## What is http-net?
An HTTP/1.1 engine that does no I/O. Feed it the bytes a socket delivered;
it hands back requests or responses as borrowed views of those bytes,
decodes their bodies, and serializes what you send back. Parsing is strict:
a message two implementations could frame differently is refused with a
specific error, never repaired. Every buffer is bounded by configuration,
and each limit is checked before the bytes it governs are buffered.
## What changed since v0.2.0
### Security - credentials no longer reach `Debug` output
In 0.2.0, `{:?}` of a `RequestHead`, a `Response`, or a fetched
`ClientResponse` printed every field value, so a request logged with `{:?}`
leaked its `Cookie` and `Authorization` values. Every `Debug`
implementation in the crate now prints the values of `authorization`,
`proxy-authorization`, `cookie`, and `set-cookie` as `<redacted N bytes>`:
`RequestHead`, `ResponseHead`, `Headers`, `HeaderValues`, the events that
carry them, `Request`, `Response`, and `blocking::ClientResponse`. Names
match in any case. The accessors still return the real bytes, and the cost
is zero unless `Debug` runs.
```rust
use http_net::{ServerConn, ServerEvent};
let mut conn = ServerConn::new();
let _ = conn.feed(b"GET / HTTP/1.1\r\nHost: a\r\nCookie: session=s3cr3t\r\n\r\n");
let ServerEvent::Request(req) = conn.poll()? else { unreachable!() };
assert!(!format!("{req:?}").contains("s3cr3t"));
assert_eq!(req.header("cookie"), Some(&b"session=s3cr3t"[..]));
# Ok::<(), http_net::HttpError>(())
```
### Changed - upgrades: `h2c` ignored, offered protocols enforced
A request whose `Upgrade` offers only `h2c` is no longer an upgrade request:
RFC 9113 section 3.1 deprecates the HTTP/2 upgrade, and proxies that
forward `Upgrade: h2c` blindly are the h2c smuggling attack. Such a request
is served as plain HTTP/1.1. `Response::switching_protocols("h2c")` and
`Request::upgrade("h2c")` are refused with the new
`HttpError::UpgradeNotOffered`.
A `101` must now name a protocol the request offered (RFC 9110 section 7.8
says a server MUST NOT switch to anything else); otherwise `respond` fails
with `UpgradeNotOffered`, writes nothing, and leaves the connection free to
send a normal response such as `426`. Names compare ASCII
case-insensitively, and a name without a version answers an offer of that
name with any version (`websocket` answers `websocket/13`). Both builders
now accept a `name/version` product token.
### Changed - stricter `Host`, a more tolerant client status line
`Host: :80` (a port with no host) and a `%` not followed by two hex digits
(`Host: a%zz`) are refused with `InvalidHost`; the same authorities in
`CONNECT` and absolute-form targets are `InvalidTarget`, and
`Request::header("host", ...)` refuses them too. Valid escapes, IPvFuture
literals, an empty port, and an empty `Host` are still accepted.
On the client side, a status line without the space after the code
(`HTTP/1.1 200`) is accepted, with an empty `reason()`. Senders must send
the space, but real servers omit it, and it cannot change framing.
### New - API items
- `VERSION`.
- `HttpError::is_peer_error` and `HttpError::is_usage_error`: whether an
error can come from the peer's bytes (the connection is then finished)
or from a caller mistake (nothing was written).
- `Limits::buffer_capacity`: the input buffer's hard capacity (64 KiB with
the defaults), so a driver can plan memory per connection.
- `RequestHead::target_form` and `TargetForm` (`Origin`, `Absolute`,
`Authority`, `Asterisk`).
- `header_all` on `RequestHead` and `ResponseHead`, returning
`HeaderValues`: every field with one name in arrival order, each
`Set-Cookie` separately. The values borrow the head, not the name.
- `content_length` on both views: the declared `Content-Length`, so
`Some(0)` and an absent field can be told apart (`framing()` still
reports `None` for both).
- `Method::is_safe`, `Method::is_idempotent`, `Method::index`, and
`MethodSet`, a bit set for routers with `write_allow` for the `Allow`
field of a `405`.
- `reason_phrase` covers every code in the IANA registry (17 added: `102`,
`103`, `207`, `208`, `226`, `305`, `402`, `407`, `423`, `424`, `425`,
`451`, `506`, `507`, `508`, `510`, `511`); `StatusClass` and
`status_class`; the `status` module with a named constant for every
registered code. One table drives both the constants and the phrases.
- `Response::from_error`: the answer to a request parse error (its status,
a short static `text/plain` body, `close`).
- `Request::head`, `Request::put`, `Request::delete`, `Request::options`.
### New - evidence
- **Fuzzing.** Six cargo-fuzz targets in `fuzz/`: `server_conn` and
`client_conn` (fuzzer-chosen splits and limits; the buffer never exceeds
its capacity, nothing is parsed after an error, the events equal a
whole-input run), `chunk_line`, `builders` (anything a builder accepts
parses back to the same values), and two differential oracles,
`reference_diff` and `httparse_diff`. CI runs each for 60 s on every
push, with the corpus outside the repository.
- **No allocation in steady state.** `tests/alloc.rs` counts allocations:
10,000 keep-alive requests on one `ServerConn`, and 10,000 responses on
one `ClientConn`, allocate nothing after warm-up. Four 10 MiB hostile
streams (endless header lines, an endless start line, endless chunk
extensions, endless chunks) never grow a connection past its buffer
capacity plus its field index.
- **Agreement with httparse.** On every request head both parsers accept,
`http-net` and `httparse` 1.10 agree on the head length, method, target,
version, and fields. Where only `httparse` accepts, `http-net` refused for
a documented reason; over every single-byte mutation of the seed requests
those refusals were `InvalidHost` (2,068), `InvalidTarget` (1,719),
`BareLf` (679), `MissingHost` (656), `InvalidTransferEncoding` (387),
`InvalidContentLength` (202), and `TransferEncodingInHttp10` (1).
- **Conformance.** `tests/conformance.rs` lists 56 RFC 9112 and RFC 9110
statements relevant to this layer, each with a check.
- **New attack vectors**, each fed whole and split at every byte: chunk
extensions that end at a lone LF or contain a CR (the Funky Chunks
TERM.EXT and EXT.TERM classes behind recent Netty, Jetty, and ASP.NET
Core advisories), 0.CL desync, eight `Expect`-based desync shapes, `h2c`
and unoffered upgrades, and `Host` escapes.
- **`no_std`.** CI builds the core for `thumbv7em-none-eabihf`, a target
with no standard library.
- The `docs/API.md` and `docs/MIGRATION.md` examples run as doctests.
### Changed - documentation and plan
`docs/API.md` is the approved 1.0 surface. `dev/PLAN.md` now holds the
design and the decision log (twelve decisions, D-1 to D-12),
`docs/MIGRATION.md` lists everything a 0.2.0 user can notice, and
`dev/ROADMAP.md` follows the library specification's plan: fuzz targets
moved from 0.9 to 0.3; the client side of `Expect`, interim responses, and
the blocking deadlines to 0.4; client pipelining to 0.5. The async adapter
is withdrawn: no crate in this family takes an async runtime.
## Testing
181 tests plus 210 doctests, all passing (`cargo test --all-features`):
- Unit tests (70) in each module, including every `HttpError` variant's
category and static `Display` text, the status registry, method
properties, `MethodSet`, upgrade matching, and the `Host` rules.
- RFC 9112 cases as data (`tests/rfc_cases.rs`, 18 tests): 17 request accept
cases, 66 request reject cases with their exact errors (the HTTP/2
preface among them, answered 505), 18 response cases, each fed whole and
one byte at a time, plus limit boundaries and the specification's 0.3
edge cases (an 8 KiB method, `max_headers = 0`, every tchar in a field
name, 10,000 one-byte chunks, a client half-close, and more).
- Smuggling (`tests/smuggling.rs`): 25 CL.TE / TE.CL / TE.TE / line-ending
vectors and 7 chunk-extension line-end vectors, each at every split point.
- Adversarial (`tests/adversarial.rs`, 28 tests, 9 new): 0.CL, `Expect`
shapes, `Host` escapes, `h2c`, and offered-protocol matching added to the
0.2.0 suite.
- Redaction (`tests/redaction.rs`, 6), allocation counting
(`tests/alloc.rs`, 10), the httparse differential
(`tests/prop_httparse.rs`, 7), the conformance table
(`tests/conformance.rs`, 56 rows), the reference differential
(`tests/prop_reference.rs`, now sharing its reference parser with the
fuzz targets), property tests (`tests/prop_roundtrip.rs`, 13), and
localhost sockets (`tests/localhost.rs`, 19).
- Fuzzing: each of the six targets ran 60 s with no finding (about 1.7 M
executions for `server_conn`, 4.3 M for `httparse_diff`).
## Performance
Criterion medians, Windows x86_64, release profile, library only (1 s
warm-up, 3 s measurement), from an interleaved run of a 0.2.0 build and the
0.3.0 build on the same machine, so machine load hits both equally:
| 12-field browser GET, parsed and answered | ~292 ns | ~298 ns |
| 100,000 pipelined GETs | ~8.7 ms | ~8.9 ms |
| 1 MiB body, `Content-Length` | ~13.5 µs | ~13.5 µs |
| 1 MiB body, chunked (4 KiB chunks) | ~17.0 µs | ~16.9 µs |
| response serialization (five fields, 1 KiB body) | ~77 ns | ~78 ns |
| client parsing a 1 KiB response | ~102 ns | ~101 ns |
The largest consistent difference is about +2% on the head and pipelined
benchmarks (the added `Upgrade` list scan and the stored target form and
`Content-Length`), inside the 5% regression budget. Two earlier runs made
while other processes loaded the machine read 30-60% slower for both
builds alike; they measured the machine, not the code.
## Migration from v0.2.0
See `docs/MIGRATION.md`. In short: code that compares `Debug` output
containing credential values, answers `h2c` upgrades, switches to a
protocol the client did not offer, or depends on `Host: :80` being accepted
needs a change. `HttpError` is `#[non_exhaustive]`, so the new variant does
not break a `match`.
## Stability commitment
Pre-1.0: the API may still change in the 0.x series, and every change is
listed in `CHANGELOG.md` and `docs/MIGRATION.md`. The API is frozen at
1.0.0 until 2.0. The MSRV is Rust 1.85; raising it after 1.0 is a major
change.
## Verification
Run on Windows x86_64 and in WSL Ubuntu (Linux x86_64), each on Rust
stable and the 1.85 MSRV toolchain:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --no-default-features -- -D warnings
cargo test
cargo test --all-features
cargo test --no-default-features
cargo build --examples
cargo build --lib --no-default-features --target thumbv7em-none-eabihf
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --no-default-features
cargo package
cargo deny check
cargo audit
cargo +nightly fuzz run <target> <corpus outside the repo> -- -max_total_time=60 # each of the six targets, WSL
```
CI runs the same checks on Linux, macOS, and Windows, stable and 1.85,
plus the `no_std` build and the 60 s fuzz smoke run per target.
## What's next
- **0.4.0:** trailers both ways, `103 Early Hints` and other interim
responses, the client side of `Expect: 100-continue`, keep-alive control
and progress for drivers, a cached `Date`, total deadlines in the
blocking server and client, and the shared grammar from `parse-net`.
- **0.5.0:** client pipelining, spans and copy-out for zero-copy passports,
stateless parsers, a standalone chunked decoder, and an allocation-free
response writer.
## Installation
```toml
[dependencies]
http-net = "0.3"
```
MSRV: Rust 1.85.
**Full Changelog:** https://github.com/jamesgober/http-net/compare/v0.2.0...v0.3.0