The content-length arm ran content_length = Some(parse) per header, so a
second Content-Length silently overwrote the first with no conflict check
(CL.CL request smuggling; h1spec #21 -> 404 instead of 400). Count
occurrences and reject any duplicate post-loop, strictly (even equal
values), reusing BadContentLength (400). A single value is still required
to be one decimal integer, so a comma-list or non-numeric keeps failing at
parse as before. Tests: differing dup, equal dup, single-CL regression.
parse_head never inspected Host, so a missing (HTTP/1.1), duplicate, or
syntactically invalid Host all passed through to the router (h1spec #8/#9/
#10 -> 404 instead of 400). Add per-header validity (RFC 3986 host[:port]
charset via valid_host) plus a post-loop presence/uniqueness check: 1.1
MUST carry exactly one valid Host; 1.0 may omit it but a duplicate/invalid
one is still 400. Unit matrix mirrors the three h1spec cases with reg-name/
port/IPv6-literal positive controls.
Pre-existing, surfaced by the toolchain bump (rust-version is 1.95; the
sandbox gates with stable 1.97). All four are cargo clippy --fix output
with the mechanical-collapse indentation hand-tidied to house style;
the parser change is semantics-preserving (a non-100-continue Expect
value now falls to the _ arm instead of an empty if — headers.append
still runs after the match either way). No fmt pass: rustfmt would
clobber the aligned-assignment style, so only the touched lines moved.
1.0 defaults to close: honoring 'Connection: Keep-Alive' without echoing
it back left spec-following clients waiting for an EOF that never came
(ab -k deadlocked to its poll timeout on response 1).
As agreed:
- serialise_response echoes 'connection: keep-alive' when keep_alive
is on AND version is 1.0. 1.1 keep-alive stays implicit.
- Error statuses (>=400) on 1.0 force keep_alive off in the conn actor
(joins the existing 1.0-stream force-close): we don't advertise reuse
on a 4xx/5xx to a protocol generation where reuse is opt-in.
- HEAD needs nothing: the echo is framing-neutral.
- Parse-error responses already carried 'connection: close'.
Tests: unit pair (1.0 echo / 1.1 implicit) + integration pair (socket
actually reused on 1.0; 404 forces close + EOF). Validated against the
original repro: ab -k -n 5000 -c 50 GET /users -> 5000/5000 keep-alive,
0 failed, ~63k rps (~18.9k without keep-alive).
Design (as agreed in the v0.4 pass):
- Dep #3 spent: sha1_smol 1.0.1 (zero transitive deps) instead of a
vendored SHA-1 — user call: no hand-rolled crypto. Consequence noted
in ROADMAP: the v0.6 wire-format JSON question now costs dep #4.
- Base64 stays in-tree but ENCODE-ONLY (RFC 4648 vectors tested): it is
an encoding, not crypto, and the decode direction (where parsing bugs
live) is deliberately not implemented.
- src/ws/{mod,handshake}.rs: validate() implements §4.2.1 (GET, 1.1,
Host, upgrade/connection token lists case-insensitively, key shape =
base64 of exactly 16 bytes, version 13). Version mismatch -> 426 +
sec-websocket-version: 13 (§4.2.2); everything else -> 400. Rejected
upgrades stay plain HTTP with keep-alive intact (tested: 400 then 101
on the same connection).
- Conn grows pub upgrade: Option<WsUpgrade> (opaque marker; chunk 3
turns it into the duplex/handler handoff payload — shape deliberately
uncommitted while the handler API is open). Conn::upgrade(self) is the
commit point: 101 + accept key + marker + halt, or the rejection.
- conn_actor: upgrade marker + status 101 -> write head, leave the HTTP
loop. Until chunk 3 that drops the fd (clients see 101 then EOF);
status check is defensive against a post-handler plug clobbering 101.
- serialise_response: 1xx no longer get content-length injected
(RFC 7230 §3.3.2). 204 left alone on purpose — separate conversation.
Accept-key path pinned to the §1.3 worked example end-to-end (unit +
wire). Suite: 34 unit + 33 integration + 2 doc.
parser: Transfer-Encoding: chunked no longer 411s — it sets
ParsedHead.chunked and the conn actor decodes. Two hard rejections at
parse time, both Malformed/400: chunked together with Content-Length
(the request-smuggling ambiguity; RFC 7230 §3.3.3 permits rejection)
and chunked on HTTP/1.0 (TE is a 1.1 mechanism). ParseError::Unsupported
is now unconstructed; kept for future framings.
conn_actor: read_chunked_body decodes incrementally from buf[head_len..],
reading on the SAME request deadline the head came in under (a stalled
chunked body dies at request_timeout exactly like a stalled CL body).
Chunk extensions are ignored; trailers are consumed and discarded.
Bounds: decoded size capped at max_body_bytes -> 413 the moment the cap
would be crossed (the CL pre-check can't see a chunked body's size up
front); size lines capped at 128 bytes and the trailer section at 8 KiB
-> 400, so framing spam can't grow the read buffer unboundedly.
Returns (decoded, raw_consumed_past_head): the keep-alive drain at the
loop bottom now drops head_len + RAW framing length (not decoded length),
so pipelined requests behind a chunked body land exactly — covered by
the trailers+pipelining test.
Tests: 3 parser unit (flagging, CL+TE reject, 1.0 reject; the old
chunked-is-Unsupported test replaced) + 6 integration (decode with
extensions, trailers + pipelined next request, 413 over decoded cap,
400 malformed size line, 400 CL+TE on the wire, stalled chunked body
killed at request_timeout with silent close).
Design decisions (per handoff Q1 user answer + Q3 proposal):
- Producer shape is PULL: the handler returns a smarm Receiver<Vec<u8>>
(RespBody::Stream / StreamBody, From<Receiver<Vec<u8>>> for ergonomics);
the producer is an actor the handler spawned. The conn actor pumps in
pump_stream(): it keeps sole ownership of the socket and of write
deadlines, and the recv() park between chunks is stoppable by the
draining registry's request_stop (stop sentinel unwinds out of
park_current; fd + registry guards clean up) — so an infinite stream is
force-stoppable at the drain deadline like any in-flight request, with
no polling. End of stream = every Sender dropped = terminating 0-chunk.
Producer contract: a send() error means the conn died; exit.
- Framing: HTTP/1.1 gets transfer-encoding: chunked and the connection
stays reusable after the terminator (keep-alive after chunked). HTTP/1.0
has no chunked TE: bytes go raw, keep-alive is forced off, EOF delimits.
User-set content-length/transfer-encoding headers are DROPPED for stream
bodies — we own the framing, and CL+chunked is a smuggling vector.
Empty producer chunks are skipped (a 0-length chunk would terminate the
framing early).
- request_timeout stays READ-phase only (unchanged). NEW Config/ConnLimits
field write_timeout (default 30s) gives every response write a per-write
budget: the fixed head+body write, each streamed chunk, and the error
paths (413/100-continue/4xx) all go through the now deadline-bounded
write_all (wait_writable_timeout, mirroring read_some). Each chunk gets
a FRESH budget — streams may outlive any whole-response clock; a single
stalled write may not (write-side slowloris). Naming/default were
flagged as a user call in the handoff: veto here if write_timeout(30s)
isn't it.
- RespBody loses derive(Clone) (Receiver isn't Clone; Clone was unused)
and gets a manual Debug.
Tests: 3 serialiser unit tests (chunked head shape, user-framing-header
stripping, 1.0 fallback) + 5 integration (chunked round-trip with decoder,
keep-alive after chunked, 1.0 EOF-delimited, shutdown force-stops an
infinite stream at drain deadline, stalled reader killed at write_timeout
with producer observing the closed channel).