294 lines
14 KiB
C++
294 lines
14 KiB
C++
|
|
//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;
|
||
|
|
}
|