Crafter.Network/tests/ShouldParseHTTP1/main.cpp

294 lines
14 KiB
C++
Raw Normal View History

feat(http1): add an HTTP/1.1 client and listener HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:45:09 +00:00
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Wire-format unit tests for the HTTP/1.1 codec. No sockets involved — the
// parser is fed the same byte sequences a peer would send, including the
// malformed ones we are supposed to refuse.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
template <typename T>
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
if (!(actual == expected)) {
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
++failures;
}
}
// Feed the message one byte at a time: any parser that only works on
// whole-message reads falls over here, and real sockets do split
// messages at arbitrary offsets.
HTTP1::MessageParser ParseByteByByte(HTTP1::MessageKind kind, std::string_view wire) {
HTTP1::MessageParser parser(kind);
for (char c : wire) parser.Feed(&c, 1);
return parser;
}
bool Rejects(HTTP1::MessageKind kind, std::string_view wire) {
try {
HTTP1::MessageParser parser(kind);
parser.Feed(wire.data(), wire.size());
parser.Finish();
return false;
} catch (const HTTP1::HTTP1ProtocolError&) {
return true;
} catch (...) {
return false;
}
}
void RequestRoundTrip() {
auto parser = ParseByteByByte(HTTP1::MessageKind::Request,
"POST /submit?id=7 HTTP/1.1\r\n"
"Host: example.test:8080\r\n"
"Content-Length: 5\r\n"
"X-Multi: a\r\n"
"X-Multi: b\r\n"
"\r\n"
"hello");
Check(parser.Complete(), "request completes");
Check(parser.KeepAlive(), "HTTP/1.1 defaults to keep-alive");
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.method, "POST", "method");
CheckEqual<std::string>(request.path, "/submit?id=7", "target keeps its query string");
CheckEqual<std::string>(request.authority, "example.test:8080", "host becomes authority");
CheckEqual<std::string>(request.body, "hello", "body");
Check(!request.headers.contains("host"), "host is not duplicated into the header map");
CheckEqual<std::string>(request.headers.at("x-multi"), "a, b", "repeated fields are joined");
}
void HeaderNamesAreCaseInsensitive() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view wire = "GET / HTTP/1.1\r\nHOST: h\r\nContent-Type: text/plain\r\n\r\n";
parser.Feed(wire.data(), wire.size());
Check(parser.Complete(), "bodyless request completes at the blank line");
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.headers.at("content-type"), "text/plain",
"field names are lowercased");
}
void ChunkedBodyWithTrailers() {
auto parser = ParseByteByByte(HTTP1::MessageKind::Request,
"PUT /upload HTTP/1.1\r\n"
"Host: h\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"5\r\nhello\r\n"
"6;ext=1\r\n world\r\n"
"0\r\n"
"X-Checksum: 42\r\n"
"\r\n");
Check(parser.Complete(), "chunked request completes at the zero chunk");
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.body, "hello world", "chunks are reassembled");
CheckEqual<std::string>(request.headers.at("x-checksum"), "42", "trailers are merged in");
}
void PipelinedRequests() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view wire = "GET /one HTTP/1.1\r\nHost: h\r\n\r\nGET /two HTTP/1.1\r\nHost: h\r\n\r\n";
parser.Feed(wire.data(), wire.size());
Check(parser.Complete(), "first pipelined request is complete");
CheckEqual<std::string>(parser.TakeRequest().path, "/one", "first target");
parser.Reset();
Check(parser.Complete(), "second request was already buffered");
CheckEqual<std::string>(parser.TakeRequest().path, "/two", "second target");
}
void ExpectContinue() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view head = "POST / HTTP/1.1\r\nHost: h\r\nExpect: 100-continue\r\nContent-Length: 2\r\n\r\n";
parser.Feed(head.data(), head.size());
Check(parser.ExpectsContinue(), "peer is waiting for 100 Continue");
parser.ContinueSent();
Check(!parser.ExpectsContinue(), "expectation clears once answered");
parser.Feed("hi", 2);
Check(parser.Complete(), "body arrives after the interim response");
}
void ConnectionCloseAndHTTP10() {
HTTP1::MessageParser closing(HTTP1::MessageKind::Request);
std::string_view wire = "GET / HTTP/1.1\r\nHost: h\r\nConnection: close\r\n\r\n";
closing.Feed(wire.data(), wire.size());
Check(!closing.KeepAlive(), "Connection: close ends the connection");
HTTP1::MessageParser legacy(HTTP1::MessageKind::Request);
std::string_view old = "GET / HTTP/1.0\r\nHost: h\r\n\r\n";
legacy.Feed(old.data(), old.size());
Check(!legacy.KeepAlive(), "HTTP/1.0 defaults to closing");
HTTP1::MessageParser legacyKeep(HTTP1::MessageKind::Request);
std::string_view oldKeep = "GET / HTTP/1.0\r\nHost: h\r\nConnection: keep-alive\r\n\r\n";
legacyKeep.Feed(oldKeep.data(), oldKeep.size());
Check(legacyKeep.KeepAlive(), "HTTP/1.0 reuses when asked explicitly");
}
void ResponseFramings() {
// Interim responses must not be mistaken for the real one.
auto early = ParseByteByByte(HTTP1::MessageKind::Response,
"HTTP/1.1 103 Early Hints\r\nLink: </s.css>\r\n\r\n"
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
Check(early.Complete(), "response after 103 completes");
HTTPResponse response = early.TakeResponse();
CheckEqual<std::string>(response.status, "200", "1xx is skipped");
CheckEqual<std::string>(response.body, "ok", "final body");
Check(!response.headers.contains("link"), "interim headers do not leak into the response");
// No content-length and no chunking: the body ends at EOF.
HTTP1::MessageParser untilClose(HTTP1::MessageKind::Response);
std::string_view wire = "HTTP/1.1 200 OK\r\n\r\nstreamed";
untilClose.Feed(wire.data(), wire.size());
Check(!untilClose.Complete(), "close-framed body is not complete before EOF");
untilClose.Finish();
Check(untilClose.Complete(), "EOF ends a close-framed body");
Check(!untilClose.KeepAlive(), "a close-framed response cannot be followed by another");
CheckEqual<std::string>(untilClose.TakeResponse().body, "streamed", "close-framed body");
// A HEAD response carries the length the body would have had.
HTTP1::MessageParser head(HTTP1::MessageKind::Response);
head.SetRequestMethod("HEAD");
std::string_view headWire = "HTTP/1.1 200 OK\r\nContent-Length: 1234\r\n\r\n";
head.Feed(headWire.data(), headWire.size());
Check(head.Complete(), "HEAD response has no body to wait for");
CheckEqual<std::string>(head.TakeResponse().headers.at("content-length"), "1234",
"HEAD keeps the advertised length");
// 204 likewise.
HTTP1::MessageParser noContent(HTTP1::MessageKind::Response);
std::string_view noContentWire = "HTTP/1.1 204 No Content\r\n\r\n";
noContent.Feed(noContentWire.data(), noContentWire.size());
Check(noContent.Complete(), "204 has no body");
}
void AbsoluteFormTarget() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view wire = "GET http://proxy.test/page HTTP/1.1\r\nHost: ignored\r\n\r\n";
parser.Feed(wire.data(), wire.size());
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.path, "/page", "absolute-form path");
CheckEqual<std::string>(request.authority, "proxy.test", "absolute-form authority wins over Host");
CheckEqual<std::string>(request.scheme, "http", "absolute-form scheme");
}
void RejectsMalformedMessages() {
Check(Rejects(HTTP1::MessageKind::Request,
"GET / HTTP/1.1\r\nHost: h\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n"),
"content-length together with transfer-encoding is refused");
Check(Rejects(HTTP1::MessageKind::Request,
"GET / HTTP/1.1\r\nHost: h\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\n"),
"disagreeing content-lengths are refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/1.1\r\nHost : h\r\n\r\n"),
"whitespace before the colon is refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/1.1\r\nHost: h\r\nContent-Length: x\r\n\r\n"),
"non-numeric content-length is refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/2.0\r\nHost: h\r\n\r\n"),
"unknown version is refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET /\r\n\r\n"),
"HTTP/0.9 simple-request is refused");
Check(Rejects(HTTP1::MessageKind::Request,
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 10\r\n\r\nshort"),
"a truncated body is refused");
Check(Rejects(HTTP1::MessageKind::Response, "HTTP/1.1 2000 Huh\r\n\r\n"),
"a four-digit status is refused");
}
void RespectsLimits() {
HTTP1::MessageLimits limits;
limits.maxBody = 8;
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, limits);
std::string_view wire = "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 9\r\n\r\n123456789";
bool threw = false;
try {
parser.Feed(wire.data(), wire.size());
} catch (const HTTP1::HTTP1ProtocolError&) {
threw = true;
}
Check(threw, "a body over the limit is refused");
}
void Serialisation() {
HTTPRequest request = CreateRequestHTTP("GET", "/x", "example.test");
request.headers["Accept"] = "text/plain";
const std::string wire = HTTP1::SerializeRequest(request);
Check(wire.starts_with("GET /x HTTP/1.1\r\n"), "request line");
Check(wire.find("host: example.test\r\n") != std::string::npos, "host header is emitted");
Check(wire.find("accept: text/plain\r\n") != std::string::npos, "headers are lowercased");
Check(wire.find("content-length") == std::string::npos, "no content-length on a bodyless GET");
Check(wire.ends_with("\r\n\r\n"), "header section is terminated");
HTTPResponse response = CreateResponseHTTP("404", "nope");
const std::string responseWire = HTTP1::SerializeResponse(response, { .keepAlive = false });
Check(responseWire.starts_with("HTTP/1.1 404 Not Found\r\n"), "status line carries a reason phrase");
Check(responseWire.find("content-length: 4\r\n") != std::string::npos, "content-length is derived");
Check(responseWire.find("connection: close\r\n") != std::string::npos, "close is announced");
Check(responseWire.ends_with("\r\n\r\nnope"), "body follows the header section");
// A 204 must not advertise a body at all.
const std::string empty = HTTP1::SerializeResponse(CreateResponseHTTP("204"));
Check(empty.find("content-length") == std::string::npos, "204 carries no content-length");
// Header injection through a value must not be possible.
HTTPResponse injected = CreateResponseHTTP("200", "x");
injected.headers["x-evil"] = "a\r\nSet-Cookie: pwned=1";
bool threw = false;
try {
(void)HTTP1::SerializeResponse(injected);
} catch (const HTTP1::HTTP1ProtocolError&) {
threw = true;
}
Check(threw, "CRLF in a header value is refused");
}
// A request we serialise must be a request we parse back identically —
// the two halves of the codec are used against each other by every
// client/server pair in this library.
void SerializeThenParse() {
HTTPRequest original = CreateRequestHTTP("POST", "/echo", "host.test:81",
{{"content-type", "application/json"}},
std::string("{\"a\":1}"));
const std::string wire = HTTP1::SerializeRequest(original);
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
parser.Feed(wire.data(), wire.size());
Check(parser.Complete(), "serialised request parses back");
HTTPRequest parsed = parser.TakeRequest();
CheckEqual(parsed.method, original.method, "round-trip method");
CheckEqual(parsed.path, original.path, "round-trip path");
CheckEqual(parsed.authority, original.authority, "round-trip authority");
CheckEqual(parsed.body, original.body, "round-trip body");
CheckEqual(parsed.headers.at("content-type"), original.headers.at("content-type"),
"round-trip header");
}
}
int main() {
RequestRoundTrip();
HeaderNamesAreCaseInsensitive();
ChunkedBodyWithTrailers();
PipelinedRequests();
ExpectContinue();
ConnectionCloseAndHTTP10();
ResponseFramings();
AbsoluteFormTarget();
RejectsMalformedMessages();
RespectsLimits();
Serialisation();
SerializeThenParse();
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}