http-net 0.9.2

Sans-IO HTTP/1.1 server and client: strict RFC 9112 parsing, smuggling defences, bounded buffers, keep-alive, chunked bodies, upgrades, a blocking std adapter, no third-party dependencies.
Documentation
# http-net v0.2.0 - The Foundation

**A sans-IO HTTP/1.1 engine, strict by default.** v0.2.0 turns the scaffold
into a working server and client core: requests and responses are parsed
from whatever bytes the socket delivered, bodies are decoded (chunked or
fixed-length), responses and requests are serialized, connections are kept
alive, and an upgraded connection is handed to the next protocol. A small
blocking adapter over `std::net` serves a handler on localhost and fetches
a URL. There are no dependencies.

## What is http-net?

The owner's HTTP/1.1 protocol engine, part of the `-net` network domain.
It does no I/O: the caller owns sockets, threads, and timers, so the same
core runs under a blocking loop, an event loop, or a test without sockets.
Its first consumer is LexerSketch Studio's localhost server, which serves
the Studio UI and upgrades one connection per window to WebSocket.

## What's new in 0.2.0

### Tier 1: serve and fetch

```rust
use http_net::{blocking, Response};

let server = blocking::Server::bind("127.0.0.1:0")?;
let addr = server.local_addr();
let stop = server.shutdown_handle();
let t = std::thread::spawn(move || server.serve(|req, _body| Response::text(200, req.path())));

let resp = blocking::get(&format!("http://{addr}/hi"))?;
assert_eq!(resp.text(), Some("/hi"));

stop.shutdown();
t.join().unwrap()?;
# Ok::<(), Box<dyn std::error::Error>>(())
```

The server is bounded (64 connections by default; the next gets `503`,
written without the accept thread ever waiting on that peer), has read and
write timeouts plus a deadline per request (30 s from a request's first
byte to the end of its body; a late request gets `408`), answers
`Expect: 100-continue` itself, adds a `date` header, answers malformed
requests with their status (`400`, `413`, `414`, `431`, `501`, `505`),
answers a handler that returns a streamed or invalid response with `500`,
and closes gracefully (lingering close, at most 1 s) so the last response
is not lost to a reset.

### The sans-IO core

`ServerConn` and `ClientConn` each own one bounded input buffer. Bytes go in
through `feed` (or `read_buf` + `commit`, to read straight into the
buffer); `poll` reports events; `respond` / `send_request` and
`send_body` / `end_body` append the wire bytes to your `Vec<u8>`. Heads are
zero-copy views (`RequestHead`, `ResponseHead`), and body events borrow
the input buffer.

### Strict parsing and smuggling defences

Everything recipients have been known to frame differently is refused with
its own error, never repaired:

| Refused | Error |
|---|---|
| `Content-Length` with `Transfer-Encoding` | `ConflictingFraming` |
| a second `Content-Length` (even equal), a list, a sign, a space, hex | `DuplicateContentLength` / `InvalidContentLength` |
| `Transfer-Encoding` other than one final `chunked` (requests and responses) | `InvalidTransferEncoding` |
| a coding other than `chunked` | `UnsupportedTransferEncoding` (501) |
| `Transfer-Encoding` in HTTP/1.0 | `TransferEncodingInHttp10` |
| obsolete line folding | `ObsFold` |
| a bare CR in a head; a bare LF as a line end | `BareCr` / `BareLf` |
| whitespace before a colon | `WhitespaceBeforeColon` |
| control bytes in a value; non-token names | `InvalidHeaderValue` / `InvalidHeaderName` |
| no `Host` in HTTP/1.1; two `Host` fields in any case; a malformed one | `MissingHost` / `InvalidHost` |
| a target outside RFC 3986, in a form the method does not allow, or an absolute form without `//authority`, with userinfo, or with an empty host | `InvalidTarget` |
| malformed chunk sizes (over 16 hex digits), extensions, or terminators | `InvalidChunkSize` / `InvalidChunkExtension` / `InvalidChunkTerminator` |
| more than 16 KiB (the header budget) of chunk extensions in one message | `ChunkExtensionsTooLarge` |

After a parse error the connection parses nothing further. Trailer fields
are validated and discarded, so they cannot reframe anything. The client
refuses bytes that arrive with no request outstanding rather than reading
them as the next response. Outgoing messages cannot carry CR/LF in a field
or set a framing header. The full table, with the RFC section behind each
row, is in [`docs/API.md`](../API.md#what-is-refused-and-why).

### Request targets

For an absolute-form target (`GET http://example.com/a?q HTTP/1.1`),
`RequestHead::host()` is the target's authority and `path()` is `/a`: RFC
9112 section 3.2.2 makes the server ignore `Host` then, so the two can
never be read differently. `Host` must still be present and valid.

### `Expect: 100-continue`

`RequestHead::expects_continue()` reports the expectation (HTTP/1.1
requests with a body), and `ServerConn::send_continue` writes the
`100 Continue` that RFC 9110 section 10.1.1 requires before a server waits
for the body. The blocking server sends it itself. This was planned for
0.5.0 and moved forward during review; interim responses other than 100
and the client side of `Expect` remain in 0.5.0.

### Bounded before allocation

| Budget | Default | Error |
|---|---|---|
| request line, status line, chunk-size line | 8 KiB | `StartLineTooLong` (414) / `ChunkLineTooLong` |
| header section (and, separately, the trailer section) | 16 KiB | `HeadersTooLarge` (431) |
| header fields (and, separately, trailer fields) | 100 | `TooManyHeaders` (431) |
| body | 1 MiB | `BodyTooLarge` (413) |
| chunk extensions per message | 16 KiB | `ChunkExtensionsTooLarge` |
| interim 1xx heads per response (client) | 16 KiB | `HeadersTooLarge` |

Declared lengths and chunk sizes are checked before their data is read; a
read-until-close response body is delivered up to the budget and refused
at the first byte past it. The input buffer never exceeds
`2 * (line + headers) + 16 KiB`. The blocking server adds time budgets:
10 s per read (also the idle keep-alive timeout), 30 s per request, 10 s
per write, at most 1 s of lingering close.

### Upgrades

`RequestHead::is_upgrade` plus `Response::switching_protocols` hands the
connection over; `remaining_input` holds the first bytes of the new
protocol. This is the seam ws-net plugs into. The blocking server's
upgrade hook receives the socket with its configured read timeout, not
what was left of the request deadline.

## Testing

108 tests plus 139 doctests, all passing (`cargo test --all-features`):

- Unit tests (44) in each module.
- RFC 9112 cases as data (`tests/rfc_cases.rs`): 15 request accept cases,
  65 request reject cases with their exact errors, 16 response cases, each
  fed whole and one byte at a time, plus limit boundaries.
- 25 smuggling vectors (`tests/smuggling.rs`), each at every split point.
- An adversarial suite (`tests/adversarial.rs`, 20 tests): absolute-form
  authority and refusals, `Host` case and duplicates, request-line
  whitespace and control bytes, `Expect: 100-continue` detection and call
  order, keep-alive after an answer at `Request`, cumulative chunk
  extensions, chunk-size and trailer edges, trailer framing fields, trailer
  limits, interim-response floods, response smuggling shapes, and integer
  edges of `Content-Length` and chunk sizes.
- Property tests (`tests/prop_roundtrip.rs`, 13): request and response
  round trips with full, chunked, and fixed-length streamed bodies over
  arbitrary splits; absolute-form round trips; keep-alive round trips;
  split invariance; no panics on arbitrary bytes from the first byte and
  behind valid heads (reaching the body, chunk, and trailer states), with
  default and tiny limits. A deeper local run with 60,000 cases per fuzz
  property and 300,000 differential cases also passed.
- A differential test against an independent whole-buffer reference parser
  (`tests/prop_reference.rs`): every single-byte mutation of eight seed
  requests, plus 8,192 random multi-byte mutations.
- Scale: 100,000 pipelined requests; a 16 KiB head fed one byte at a time.
- Localhost sockets (`tests/localhost.rs`, 19): Tier-1 server and client,
  keep-alive, pipelining, a 200 KB chunked upload echoed back, streamed
  responses, error answers, `Expect: 100-continue` (100 before the body,
  413 instead when over the limit), slow heads and slow bodies cut off by
  the request timeout, idle keep-alive time not counted, the connection
  cap, a refused client that keeps sending, lingering close with a
  trickling client, handler misuse answered with 500, the upgrade hand-off
  (including the restored read timeout), and shutdown.

## Performance

Criterion medians, Windows x86_64, release profile, library only (1 s
warm-up, 3 s measurement):

| Benchmark | Median |
|---|---:|
| 12-field browser GET, parsed and answered | ~280 ns |
| 100,000 pipelined GETs | ~8.4 ms (about 11.9 M requests/s) |
| 1 MiB body, `Content-Length` / chunked (4 KiB chunks) | ~13 µs / ~17 µs |
| response serialization (five fields, 1 KiB body) | ~75 ns |
| client parsing a 1 KiB response | ~98 ns |

An earlier run on the same machine, made while other builds were running,
measured two to three times slower. The two runs are not an A/B comparison
of the code: the review added one more field-name compare per header and a
running total per chunk line, and no regression was seen, but the speed-up
comes from machine load, not from those changes.

## Breaking changes

None: v0.1.0 had no public API.

## Verification

Run on Windows x86_64 (Rust stable, 1.99, 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
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo +1.85 clippy --all-targets --all-features -- -D warnings
cargo +1.99 clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --no-default-features
cargo +1.85 test --all-features
cargo bench --no-run
```

## What's next

- **0.5.0:** a client with several outstanding pipelined requests, the
  client side of `Expect: 100-continue`, interim responses other than 100
  from the server, a total deadline per response on the blocking client,
  an async adapter.
- **0.9.0:** `cargo fuzz` targets and conformance tests.
- **1.0.0:** frozen after LexerSketch Studio and `ls-publish` use it.

## Installation

```toml
[dependencies]
http-net = "0.2"
```

MSRV: Rust 1.85.