//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 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(request.method, "POST", "method"); CheckEqual(request.path, "/submit?id=7", "target keeps its query string"); CheckEqual(request.authority, "example.test:8080", "host becomes authority"); CheckEqual(request.body, "hello", "body"); Check(!request.headers.contains("host"), "host is not duplicated into the header map"); CheckEqual(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(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(request.body, "hello world", "chunks are reassembled"); CheckEqual(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(parser.TakeRequest().path, "/one", "first target"); parser.Reset(); Check(parser.Complete(), "second request was already buffered"); CheckEqual(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: \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(response.status, "200", "1xx is skipped"); CheckEqual(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(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(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(request.path, "/page", "absolute-form path"); CheckEqual(request.authority, "proxy.test", "absolute-form authority wins over Host"); CheckEqual(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; }