diff --git a/implementations/Crafter.Network-ClientHTTP1.cpp b/implementations/Crafter.Network-ClientHTTP1.cpp new file mode 100644 index 0000000..13ccb5a --- /dev/null +++ b/implementations/Crafter.Network-ClientHTTP1.cpp @@ -0,0 +1,155 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include + +module Crafter.Network:ClientHTTP1_impl; +import :ClientHTTP1; +import :ClientTCP; +import :HTTP; +import :HTTP1; +import Crafter.Thread; +import std; + +using namespace Crafter; + +namespace { + // Read whatever is available, waiting at most `timeout`. Returns 0 on a + // clean close by the peer. ClientTCP::RecieveSync() can't express a + // timeout — and a server that accepts the connection and then says + // nothing would hang Send() forever — so the read is done here. + std::size_t ReadSome(int socketid, char* buffer, std::size_t size, + std::chrono::milliseconds timeout) { + for (;;) { + pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 }; + const int ready = poll(&pfd, 1, static_cast(timeout.count())); + if (ready < 0) { + if (errno == EINTR) continue; + throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno)); + } + if (ready == 0) throw std::runtime_error("timed out waiting for the response"); + + const auto read = recv(socketid, buffer, size, 0); + if (read < 0) { + if (errno == EINTR) continue; + throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno)); + } + return static_cast(read); + } + } + + // Host header value. The port is elided when it is the scheme default, + // which is what every other HTTP/1.1 client on the wire does and what + // virtual-host matching on the far side tends to expect. + std::string DefaultAuthority(const std::string& host, std::uint16_t port) { + if (port == 80) return host; + return host + ":" + std::to_string(port); + } +} + +struct ClientHTTP1::Impl { + std::string host; + std::uint16_t port; + std::unique_ptr tcp; + std::chrono::milliseconds timeout{30000}; + + void Connect() { + tcp = std::make_unique(host, port); + } + + void Close() { + tcp.reset(); + } + + // One request/response exchange on the current connection. `received` + // is set as soon as any response byte arrives, which is what decides + // whether a failure is safe to replay on a new connection. + HTTPResponse Exchange(const std::string& wire, std::string_view method, + const HTTP1::MessageLimits& limits, bool& received) { + tcp->Send(wire.data(), static_cast(wire.size())); + + HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits); + parser.SetRequestMethod(method); + std::vector chunk(16 * 1024); + while (!parser.Complete()) { + const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout); + if (read == 0) { + // Peer closed. Completes a response framed by close; + // anything else throws out of Finish(). + parser.Finish(); + break; + } + received = true; + parser.Feed(chunk.data(), read); + } + if (!parser.Complete()) { + throw std::runtime_error("connection closed before a complete response arrived"); + } + HTTPResponse response = parser.TakeResponse(); + if (!parser.KeepAlive()) Close(); + return response; + } +}; + +ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port) + : host(host), port(port), impl(std::make_unique(std::string(host), port)) {} + +ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port) + : ClientHTTP1(host.c_str(), port) {} + +ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default; +ClientHTTP1::~ClientHTTP1() = default; + +bool ClientHTTP1::Connected() const noexcept { + return impl && impl->tcp != nullptr; +} + +void ClientHTTP1::Disconnect() { + if (impl) impl->Close(); +} + +HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) { + HTTPRequest prepared = request; + if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port); + const std::string wire = HTTP1::SerializeRequest(prepared); + const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method; + + // Two attempts at most, and the second one only for the keep-alive + // race: the peer may have closed a pooled connection at the same moment + // we wrote to it, which is indistinguishable from success until the + // read fails. Nothing is replayed once a response byte has arrived. + for (int attempt = 0;; ++attempt) { + const bool reused = impl->tcp != nullptr; + if (!reused) impl->Connect(); + + bool received = false; + try { + return impl->Exchange(wire, method, limits, received); + } catch (const std::exception&) { + impl->Close(); + if (attempt == 0 && reused && !received) continue; + throw; + } + } +} + +void ClientHTTP1::SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError) { + HTTPRequest copy = request; + ThreadPool::Enqueue([this, copy = std::move(copy), + onSuccess = std::move(onSuccess), + onError = std::move(onError)]() mutable { + try { + HTTPResponse response = this->Send(copy); + if (onSuccess) onSuccess(std::move(response)); + } catch (const std::exception& e) { + if (onError) onError(e.what()); + } catch (...) { + if (onError) onError("unknown error"); + } + }); +} diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp new file mode 100644 index 0000000..4808fa9 --- /dev/null +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -0,0 +1,301 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include + +module Crafter.Network:ListenerHTTP1_impl; +import :ListenerHTTP1; +import :ListenerTCP; +import :ClientTCP; +import :HTTP; +import :HTTP1; +import std; + +using namespace Crafter; + +namespace { + enum class ReadStatus { Data, Closed, TimedOut }; + + // Wait for readable data with a deadline, so an idle or stalled peer + // releases the connection thread instead of holding it forever. + ReadStatus ReadSome(int socketid, char* buffer, std::size_t size, + std::chrono::milliseconds timeout, std::size_t& read) { + read = 0; + for (;;) { + pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 }; + const int ready = poll(&pfd, 1, static_cast(timeout.count())); + if (ready < 0) { + if (errno == EINTR) continue; + return ReadStatus::Closed; + } + if (ready == 0) return ReadStatus::TimedOut; + + const auto got = recv(socketid, buffer, size, 0); + if (got < 0) { + if (errno == EINTR) continue; + return ReadStatus::Closed; + } + if (got == 0) return ReadStatus::Closed; + read = static_cast(got); + return ReadStatus::Data; + } + } + + // Everything up to '?' — used as the fallback route key so `/thing?x=1` + // reaches the handler registered for `/thing`. Browsers append query + // strings freely, and requiring handlers to register every variant is + // not a workable API. + std::string_view PathWithoutQuery(std::string_view target) { + return target.substr(0, target.find('?')); + } +} + +// One accepted connection: the socket, the thread serving it, and a flag +// the accept loop uses to join finished threads without blocking. +struct HTTP1Connection { + std::unique_ptr client; + std::thread thread; + std::atomic finished{false}; +}; + +struct ListenerHTTP1::Impl { + ListenerHTTP1* owner = nullptr; + std::unique_ptr listener; + std::mutex mutex; + std::vector> connections; + std::atomic running{true}; + std::atomic accepted{0}; + + // Join threads whose connection has ended. Called from the accept loop + // with `mutex` held, so a connection is never reaped mid-registration. + void ReapLocked() { + std::erase_if(connections, [](const std::unique_ptr& connection) { + if (!connection->finished.load()) return false; + if (connection->thread.joinable()) connection->thread.join(); + return true; + }); + } + + void Adopt(ClientTCP* accepted) { + auto connection = std::make_unique(); + connection->client.reset(accepted); + HTTP1Connection* pointer = connection.get(); + + std::lock_guard lock(mutex); + if (!running.load()) return; // shutting down — let the socket close + ReapLocked(); + this->accepted.fetch_add(1); + connection->thread = std::thread([this, pointer] { + try { + Serve(*pointer->client); + } catch (...) { + // A connection dying must never take the server with it. + } + pointer->finished.store(true); + }); + connections.push_back(std::move(connection)); + } + + void Send(ClientTCP& client, const std::string& wire) { + client.Send(wire.data(), static_cast(wire.size())); + } + + HTTPResponse Dispatch(const HTTPRequest& request) { + const auto& routes = owner->routes; + auto route = routes.find(request.path); + if (route == routes.end()) { + const std::string bare(PathWithoutQuery(request.path)); + route = routes.find(bare); + } + if (route == routes.end()) { + return CreateResponseHTTP("404", "Not Found"); + } + try { + return route->second(request); + } catch (const std::exception& error) { + return CreateResponseHTTP("500", std::string(error.what())); + } catch (...) { + return CreateResponseHTTP("500", "Internal Server Error"); + } + } + + // Serve one connection until the peer goes away, asks to close, stalls, + // or sends something we refuse to parse. + void Serve(ClientTCP& client) { + HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits); + std::vector chunk(16 * 1024); + bool keepAlive = true; + + while (running.load() && keepAlive) { + // ── Read one complete request ───────────────────────────── + bool closed = false; + try { + while (!parser.Complete()) { + const bool idle = parser.AtMessageBoundary(); + const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout; + std::size_t read = 0; + const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(), + timeout, read); + if (status == ReadStatus::TimedOut) { + // An idle keep-alive connection is simply dropped; + // a half-sent request earns a 408 first. + if (!idle) { + try { + Send(client, HTTP1::SerializeResponse( + CreateResponseHTTP("408", "Request Timeout"), + { .keepAlive = false })); + } catch (...) {} + } + return; + } + if (status == ReadStatus::Closed) { + if (parser.AtMessageBoundary()) return; // clean end of connection + parser.Finish(); // throws if truncated + closed = true; + break; + } + parser.Feed(chunk.data(), read); + // The peer is holding its body back until we say go. + if (parser.ExpectsContinue()) { + Send(client, HTTP1::SerializeContinue()); + parser.ContinueSent(); + } + } + } catch (const HTTP1::HTTP1ProtocolError& error) { + try { + Send(client, HTTP1::SerializeResponse( + CreateResponseHTTP("400", std::string(error.what())), + { .keepAlive = false })); + } catch (...) {} + return; + } catch (...) { + return; + } + + if (!parser.Complete()) return; + + // ── Dispatch and answer ─────────────────────────────────── + const bool http10 = parser.Version() == HTTP1::kVersion10; + const bool head = parser.Method() == "HEAD"; + keepAlive = parser.KeepAlive() && !closed && running.load(); + + HTTPRequest request = parser.TakeRequest(); + HTTPResponse response = Dispatch(request); + // A handler may end the connection by answering with + // `connection: close`. The header itself is hop-by-hop and gets + // re-derived by the serialiser, so this is where it takes effect. + if (auto connection = response.headers.find("connection"); + connection != response.headers.end() && HTTP1::HasToken(connection->second, "close")) { + keepAlive = false; + } + if (!response.headers.contains("date")) { + response.headers["date"] = HTTP1::FormatHTTPDate(std::chrono::system_clock::now()); + } + + try { + Send(client, HTTP1::SerializeResponse(response, { + .keepAlive = keepAlive, + // HTTP/1.0 peers only reuse a connection they were told + // stays open. + .explicitKeepAlive = http10, + .omitBody = head, + })); + } catch (...) { + return; // peer vanished mid-response + } + + if (!keepAlive) return; + try { + // Rearms for the next request, parsing anything the peer + // already pipelined behind this one. + parser.Reset(); + } catch (const HTTP1::HTTP1ProtocolError&) { + return; + } + } + } +}; + +ListenerHTTP1::ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes) + : routes(std::move(routes)) + , impl(std::make_unique()) +{ + impl->owner = this; + Impl* state = impl.get(); + impl->listener = std::make_unique(port, [state](ClientTCP* client) { + state->Adopt(client); + }); +} + +ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept + : routes(std::move(other.routes)) + , keepAliveTimeout(other.keepAliveTimeout) + , requestTimeout(other.requestTimeout) + , limits(other.limits) + , impl(std::move(other.impl)) +{ + // The accept callback reaches the routes through Impl::owner, so the + // back-pointer has to follow the object. + if (impl) impl->owner = this; +} + +ListenerHTTP1::~ListenerHTTP1() { + if (impl) Stop(); +} + +void ListenerHTTP1::Listen() { + if (!impl || !impl->listener) return; + // Accept on this thread; each connection is served on its own thread + // (see Impl::Adopt), so a slow keep-alive peer can't stall the loop. + impl->listener->ListenSyncSync(); +} + +void ListenerHTTP1::Stop() { + if (!impl) return; + if (!impl->running.exchange(false)) return; + if (impl->listener) impl->listener->Stop(); + + std::vector> closing; + { + std::lock_guard lock(impl->mutex); + for (auto& connection : impl->connections) { + // Wake the serving thread out of poll()/recv() without closing + // the descriptor underneath it. + if (connection->client) shutdown(connection->client->socketid, SHUT_RDWR); + } + closing = std::move(impl->connections); + impl->connections.clear(); + } + for (auto& connection : closing) { + if (connection->thread.joinable()) connection->thread.join(); + } +} + +std::size_t ListenerHTTP1::ConnectionCount() const { + if (!impl) return 0; + std::lock_guard lock(impl->mutex); + return impl->connections.size(); +} + +std::uint64_t ListenerHTTP1::AcceptedCount() const { + return impl ? impl->accepted.load() : 0; +} + +ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes) + : listener(port, std::move(routes)) + , thread(&ListenerHTTP1::Listen, &listener) +{} + +ListenerAsyncHTTP1::~ListenerAsyncHTTP1() { + Stop(); +} + +void ListenerAsyncHTTP1::Stop() { + listener.Stop(); + if (thread.joinable()) thread.join(); +} diff --git a/interfaces/Crafter.Network-ClientHTTP1.cppm b/interfaces/Crafter.Network-ClientHTTP1.cppm new file mode 100644 index 0000000..bb5a446 --- /dev/null +++ b/interfaces/Crafter.Network-ClientHTTP1.cppm @@ -0,0 +1,69 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Network:ClientHTTP1; +import std; +import :HTTP; +import :HTTP1; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + // HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3. + // The request/response types are the ones the HTTP/3 client uses, so + // swapping ClientHTTP for ClientHTTP1 is a one-line change at the call + // site. + // + // The connection is persistent: the first Send() dials, and later calls + // reuse the socket unless the peer asked for it to be closed + // (`Connection: close`, or an HTTP/1.0 response without + // `Connection: keep-alive`). A reused connection that turns out to have + // been closed by the peer in the meantime — the unavoidable race in + // HTTP/1.1 keep-alive — is redialled once and the request replayed; + // a freshly dialled connection is never replayed on, so a genuinely + // broken server surfaces as an exception rather than a retry loop. + // + // Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller + // at a time; distinct instances are independent. + // + // No TLS. This talks `http://`; for an encrypted transport use + // ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in + // front of the HTTP/1.1 endpoint. + export class ClientHTTP1 { + public: + std::string host; + std::uint16_t port; + + ClientHTTP1(const char* host, std::uint16_t port); + ClientHTTP1(std::string host, std::uint16_t port); + + ~ClientHTTP1(); + ClientHTTP1(const ClientHTTP1&) = delete; + ClientHTTP1(ClientHTTP1&&) noexcept; + + // Send a request and read the full response. `authority` defaults to + // the host:port this client was constructed with; `scheme` is + // ignored (the transport is plaintext). + HTTPResponse Send(const HTTPRequest& request); + + // Send a request and deliver the response (or the error text) via + // callback, on Crafter.Thread's ThreadPool. + void SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError); + + // Whether a pooled connection is currently open. Mostly useful for + // tests asserting that keep-alive actually kept the socket. + bool Connected() const noexcept; + + // Drop the pooled connection; the next Send() dials again. + void Disconnect(); + + // Limits applied to responses. Set before the first Send(). + HTTP1::MessageLimits limits; + + private: + struct Impl; + std::unique_ptr impl; + }; +} +#endif diff --git a/interfaces/Crafter.Network-HTTP1.cppm b/interfaces/Crafter.Network-HTTP1.cppm new file mode 100644 index 0000000..db84c02 --- /dev/null +++ b/interfaces/Crafter.Network-HTTP1.cppm @@ -0,0 +1,777 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// HTTP/1.1 wire format — RFC 9112 syntax on top of the RFC 9110 semantics +// already modelled by :HTTP (HTTPRequest / HTTPResponse are shared with the +// HTTP/3 stack, so a route handler written for one works unchanged on the +// other). +// +// Scope: +// - request / response serialisation, with the framing headers +// (host, content-length, transfer-encoding, connection) owned by the +// serialiser rather than the caller +// - an incremental parser that consumes arbitrary socket chunks and +// surfaces one message at a time, so a single connection can carry a +// keep-alive series (and pipelined requests) +// - both body framings that matter in practice: content-length and +// `Transfer-Encoding: chunked` (plus read-to-EOF for responses that +// use neither, RFC 9112 §6.3 item 8) +// +// Deliberately rejected rather than guessed at, because getting these +// wrong is how request smuggling happens (RFC 9112 §11.2): +// - Content-Length together with Transfer-Encoding +// - conflicting duplicate Content-Length values +// - whitespace between a field name and its colon +// - CR/LF inside a field value we are asked to serialise +// +// This partition has no transport dependency; ClientHTTP1 and ListenerHTTP1 +// pair it with :ClientTCP / :ListenerTCP. + +export module Crafter.Network:HTTP1; +import std; +import :HTTP; + +namespace Crafter::HTTP1 { + // ---------------- Versions ---------------- + export inline constexpr std::string_view kVersion10 = "HTTP/1.0"; + export inline constexpr std::string_view kVersion11 = "HTTP/1.1"; + + // ---------------- Errors ---------------- + // Thrown for any input we refuse to interpret. Callers turn this into a + // 400 (server side) or propagate it out of Send() (client side). + export class HTTP1ProtocolError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; + }; + + // ---------------- Lexical helpers (RFC 9110 §5.6) ---------------- + inline bool IsTChar(unsigned char c) { + constexpr std::string_view extra = "!#$%&'*+-.^_`|~"; + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || extra.find(static_cast(c)) != std::string_view::npos; + } + + inline bool IsToken(std::string_view s) { + return !s.empty() && std::ranges::all_of(s, [](char c) { + return IsTChar(static_cast(c)); + }); + } + + inline std::string ToLowerAscii(std::string_view s) { + std::string out(s); + std::ranges::transform(out, out.begin(), [](unsigned char c) { + return static_cast(c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c); + }); + return out; + } + + inline bool EqualsIgnoreCase(std::string_view a, std::string_view b) { + return std::ranges::equal(a, b, [](unsigned char x, unsigned char y) { + auto lower = [](unsigned char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; }; + return lower(x) == lower(y); + }); + } + + // Optional whitespace — SP / HTAB only (RFC 9110 §5.6.3). + inline std::string_view TrimOWS(std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1); + return s; + } + + // Split a comma-separated field value into trimmed tokens (#rule). + export inline std::vector SplitCommaList(std::string_view value) { + std::vector parts; + while (!value.empty()) { + auto comma = value.find(','); + parts.push_back(TrimOWS(value.substr(0, comma))); + if (comma == std::string_view::npos) break; + value.remove_prefix(comma + 1); + } + std::erase_if(parts, [](std::string_view p) { return p.empty(); }); + return parts; + } + + // Whether a comma-separated field value contains `token`, compared + // case-insensitively — the shape of `Connection`, `Expect`, + // `Transfer-Encoding` and friends. + export inline bool HasToken(std::string_view fieldValue, std::string_view token) { + return std::ranges::any_of(SplitCommaList(fieldValue), [token](std::string_view candidate) { + return EqualsIgnoreCase(candidate, token); + }); + } + + // ---------------- Status reason phrases ---------------- + // HTTP/1.1 keeps a reason phrase in the status line. It carries no + // meaning (RFC 9112 §4) but some ancient clients log it, and an empty + // one trips up a few proxies, so emit the registered text where we know + // it and a generic class name otherwise. + export inline std::string_view ReasonPhrase(std::string_view status) { + static const std::unordered_map known = { + {"100", "Continue"}, {"101", "Switching Protocols"}, {"103", "Early Hints"}, + {"200", "OK"}, {"201", "Created"}, {"202", "Accepted"}, {"204", "No Content"}, + {"206", "Partial Content"}, + {"301", "Moved Permanently"}, {"302", "Found"}, {"303", "See Other"}, + {"304", "Not Modified"}, {"307", "Temporary Redirect"}, {"308", "Permanent Redirect"}, + {"400", "Bad Request"}, {"401", "Unauthorized"}, {"403", "Forbidden"}, + {"404", "Not Found"}, {"405", "Method Not Allowed"}, {"408", "Request Timeout"}, + {"409", "Conflict"}, {"411", "Length Required"}, {"413", "Content Too Large"}, + {"414", "URI Too Long"}, {"415", "Unsupported Media Type"}, + {"426", "Upgrade Required"}, {"429", "Too Many Requests"}, + {"431", "Request Header Fields Too Large"}, + {"500", "Internal Server Error"}, {"501", "Not Implemented"}, + {"502", "Bad Gateway"}, {"503", "Service Unavailable"}, {"504", "Gateway Timeout"}, + {"505", "HTTP Version Not Supported"}, + }; + if (auto it = known.find(status); it != known.end()) return it->second; + if (status.size() == 3) { + switch (status[0]) { + case '1': return "Informational"; + case '2': return "Success"; + case '3': return "Redirection"; + case '4': return "Client Error"; + case '5': return "Server Error"; + default: break; + } + } + return "Unknown"; + } + + // IMF-fixdate, the only date format an HTTP/1.1 sender may generate + // (RFC 9110 §5.6.7). Always UTC. + export inline std::string FormatHTTPDate(std::chrono::system_clock::time_point when) { + return std::format("{:%a, %d %b %Y %H:%M:%S} GMT", + std::chrono::floor(when)); + } + + // ---------------- Serialisation ---------------- + export struct RequestOptions { + // false emits `connection: close`, telling the peer we will not + // reuse the connection after this exchange. + bool keepAlive = true; + }; + + export struct ResponseOptions { + bool keepAlive = true; + // HTTP/1.0 peers have no keep-alive by default, so reuse has to be + // stated explicitly for them (RFC 9112 §9.3). + bool explicitKeepAlive = false; + // Response to HEAD: send the header section, including the + // content-length the body *would* have had, but no body. + bool omitBody = false; + }; + + // Headers whose value is derived from the message rather than copied + // from the caller's map — emitting the caller's copy as well would + // produce a duplicate, and for content-length a smuggling vector. + inline bool IsFramingHeader(std::string_view lowerName) { + return lowerName == "host" || lowerName == "content-length" + || lowerName == "transfer-encoding" || lowerName == "connection"; + } + + inline void ValidateFieldName(std::string_view name) { + if (!IsToken(name)) { + throw HTTP1ProtocolError("invalid header field name: '" + std::string(name) + "'"); + } + } + + // A CR or LF smuggled through a field value would split the message. + inline void ValidateFieldValue(std::string_view name, std::string_view value) { + if (value.find_first_of("\r\n") != std::string_view::npos) { + throw HTTP1ProtocolError("header '" + std::string(name) + "' contains CR/LF"); + } + } + + inline void AppendHeaders(std::string& out, + const std::unordered_map& headers) { + for (const auto& [name, value] : headers) { + // HTTP/3 pseudo-headers have no HTTP/1.1 equivalent — they are + // already represented by the start line. + if (name.empty() || name.front() == ':') continue; + std::string lower = ToLowerAscii(name); + if (IsFramingHeader(lower)) continue; + ValidateFieldName(lower); + ValidateFieldValue(lower, value); + out += lower; + out += ": "; + out += value; + out += "\r\n"; + } + } + + // Serialise a request in origin-form. `authority` becomes the mandatory + // Host header (RFC 9112 §3.2); ClientHTTP1 fills it in from the + // connection when the caller left it empty. + export inline std::string SerializeRequest(const HTTPRequest& request, RequestOptions options = {}) { + const std::string method = request.method.empty() ? std::string("GET") : request.method; + const std::string target = request.path.empty() ? std::string("/") : request.path; + if (!IsToken(method)) throw HTTP1ProtocolError("invalid request method: '" + method + "'"); + if (target.find_first_of(" \r\n") != std::string::npos) { + throw HTTP1ProtocolError("invalid request target: '" + target + "'"); + } + ValidateFieldValue("host", request.authority); + + std::string out; + out.reserve(256 + request.headers.size() * 32 + request.body.size()); + out += method; + out += ' '; + out += target; + out += " HTTP/1.1\r\n"; + out += "host: "; + out += request.authority; + out += "\r\n"; + AppendHeaders(out, request.headers); + // A body always gets an explicit length; so does a method that is + // normally expected to carry one, since some servers answer 411 for + // a bodyless POST that omits it. + if (!request.body.empty() || method == "POST" || method == "PUT" || method == "PATCH") { + out += "content-length: "; + out += std::to_string(request.body.size()); + out += "\r\n"; + } + if (!options.keepAlive) out += "connection: close\r\n"; + out += "\r\n"; + out += request.body; + return out; + } + + export inline std::string SerializeResponse(const HTTPResponse& response, ResponseOptions options = {}) { + std::string status = response.status.empty() ? std::string("200") : response.status; + if (status.size() != 3 || !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) { + throw HTTP1ProtocolError("status must be three digits, got '" + status + "'"); + } + + std::string out; + out.reserve(256 + response.headers.size() * 32 + response.body.size()); + out += "HTTP/1.1 "; + out += status; + out += ' '; + out += ReasonPhrase(status); + out += "\r\n"; + AppendHeaders(out, response.headers); + // 1xx / 204 / 304 must not carry a content-length at all — sending + // one makes some clients wait for a body that never arrives. + const bool bodyless = status[0] == '1' || status == "204" || status == "304"; + if (!bodyless) { + out += "content-length: "; + out += std::to_string(response.body.size()); + out += "\r\n"; + } + if (!options.keepAlive) out += "connection: close\r\n"; + else if (options.explicitKeepAlive) out += "connection: keep-alive\r\n"; + out += "\r\n"; + if (!options.omitBody && !bodyless) out += response.body; + return out; + } + + // The interim response a server owes a client that sent + // `Expect: 100-continue` before it will send its body (RFC 9110 §10.1.1). + export inline std::string SerializeContinue() { + return "HTTP/1.1 100 Continue\r\n\r\n"; + } + + // ---------------- Incremental parser ---------------- + export enum class MessageKind { Request, Response }; + + // Bounds on what we are willing to buffer before declaring the peer + // hostile. maxBody applies to the decoded body, so it also caps a + // chunked stream that never ends. + export struct MessageLimits { + std::size_t maxStartLine = 8 * 1024; + std::size_t maxHeaderSection = 64 * 1024; + std::uint64_t maxBody = 512ull * 1024 * 1024; + }; + + // Feed socket bytes in with Feed(); call Finish() when the peer closes + // its send side. Complete() then reports whether a whole message is + // available, and Take*() moves it out. Reset() rearms for the next + // message on the same connection, keeping any bytes that already + // belonged to it (HTTP/1.1 pipelining). + export class MessageParser { + public: + explicit MessageParser(MessageKind kind, MessageLimits limits = {}) + : kind(kind), limits(limits) {} + + // Responses are framed partly by the request that provoked them: a + // HEAD response has no body no matter what its content-length says. + // Tell the parser before feeding it bytes. + void SetRequestMethod(std::string_view requestMethod) { + headRequest = EqualsIgnoreCase(requestMethod, "HEAD"); + } + + void Feed(const char* data, std::size_t size) { + if (size != 0) buffer.append(data, size); + Advance(); + } + + void Feed(std::span bytes) { Feed(bytes.data(), bytes.size()); } + + // The peer closed. Completes a read-to-EOF body; anything else + // that is mid-message is a truncation error. + void Finish() { + eof = true; + Advance(); + } + + bool HeadersComplete() const noexcept { return headersDone; } + bool Complete() const noexcept { return state == State::Done; } + + // True while the peer is waiting for `100 Continue` before it sends + // the body. Clear it with ContinueSent() once the interim response + // is on the wire. + bool ExpectsContinue() const noexcept { return expectContinue && !Complete(); } + void ContinueSent() noexcept { expectContinue = false; } + + // Whether the connection may carry another message after this one. + // Meaningful once HeadersComplete(). + bool KeepAlive() const noexcept { return keepAlive; } + + // No partial message buffered — a clean point to stop reading. + bool AtMessageBoundary() const noexcept { + return state == State::StartLine && pos == buffer.size() && !headersDone; + } + + std::string_view Method() const noexcept { return method; } + std::string_view Version() const noexcept { return version; } + std::string_view Status() const noexcept { return status; } + + HTTPRequest TakeRequest() { + if (state != State::Done) throw HTTP1ProtocolError("request is not complete"); + HTTPRequest request; + request.method = std::move(method); + request.path = std::move(path); + request.scheme = scheme.empty() ? std::string("http") : std::move(scheme); + // Absolute-form targets carry their own authority and win over + // Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way + // it lives in the named field, matching the HTTP/3 shape, so it + // is removed from the header map. + if (authority.empty()) { + if (auto it = headers.find("host"); it != headers.end()) authority = it->second; + } + headers.erase("host"); + request.authority = std::move(authority); + request.headers = std::move(headers); + request.body = std::move(body); + return request; + } + + HTTPResponse TakeResponse() { + if (state != State::Done) throw HTTP1ProtocolError("response is not complete"); + HTTPResponse response; + response.status = std::move(status); + response.headers = std::move(headers); + response.body = std::move(body); + return response; + } + + // Rearm for the next message. Any bytes past the one just taken are + // retained and parsed immediately, so a pipelined request can be + // Complete() as soon as this returns. + void Reset() { + buffer.erase(0, pos); + pos = 0; + state = State::StartLine; + method.clear(); + path.clear(); + scheme.clear(); + authority.clear(); + version.clear(); + status.clear(); + lastHeaderName.clear(); + headers.clear(); + body.clear(); + headerBytes = 0; + bodyRemaining = 0; + chunkRemaining = 0; + bodyMode = BodyMode::None; + headersDone = false; + expectContinue = false; + keepAlive = true; + headRequest = false; + Advance(); + } + + private: + enum class State { StartLine, Headers, Body, ChunkSize, ChunkData, ChunkTrailingCRLF, Trailers, Done }; + enum class BodyMode { None, Length, Chunked, UntilClose }; + + // Read one CRLF-terminated line. A bare LF is accepted as well: it + // is not legal to generate, but recipients may recognise it + // (RFC 9112 §2.2) and some embedded clients still emit it. + std::optional TryReadLine(std::size_t maxLength) { + const std::size_t nl = buffer.find('\n', pos); + if (nl == std::string::npos) { + if (buffer.size() - pos > maxLength) { + throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes"); + } + if (eof && buffer.size() != pos) { + throw HTTP1ProtocolError("connection closed mid-message"); + } + return std::nullopt; + } + std::size_t end = nl; + if (end > pos && buffer[end - 1] == '\r') --end; + if (end - pos > maxLength) { + throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes"); + } + std::string_view line(buffer.data() + pos, end - pos); + pos = nl + 1; + return line; + } + + void ParseRequestLine(std::string_view line) { + const auto first = line.find(' '); + if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed request line"); + const auto second = line.find(' ', first + 1); + if (second == std::string_view::npos) { + // HTTP/0.9 simple-request. Not supported, and accepting it + // would leave us unable to answer with headers. + throw HTTP1ProtocolError("malformed request line (missing version)"); + } + method = std::string(line.substr(0, first)); + const std::string_view target = line.substr(first + 1, second - first - 1); + version = std::string(line.substr(second + 1)); + if (!IsToken(method)) throw HTTP1ProtocolError("invalid method token"); + if (target.empty()) throw HTTP1ProtocolError("empty request target"); + CheckVersion(); + + if (target.front() == '/') { + path = std::string(target); // origin-form + } else if (target == "*") { + path = "*"; // asterisk-form (OPTIONS) + } else if (const auto sep = target.find("://"); sep != std::string_view::npos) { + // absolute-form, as sent to a proxy. + scheme = ToLowerAscii(target.substr(0, sep)); + const std::string_view rest = target.substr(sep + 3); + const auto slash = rest.find('/'); + authority = std::string(rest.substr(0, slash)); + path = slash == std::string_view::npos ? std::string("/") : std::string(rest.substr(slash)); + } else { + // authority-form — only legal for CONNECT, which this + // listener does not implement. + authority = std::string(target); + path = std::string(target); + } + } + + void ParseStatusLine(std::string_view line) { + const auto first = line.find(' '); + if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed status line"); + version = std::string(line.substr(0, first)); + CheckVersion(); + std::string_view rest = TrimOWS(line.substr(first + 1)); + const auto space = rest.find(' '); + status = std::string(space == std::string_view::npos ? rest : rest.substr(0, space)); + if (status.size() != 3 + || !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) { + throw HTTP1ProtocolError("malformed status code '" + status + "'"); + } + // The reason phrase is discarded: it carries no semantics and + // HTTPResponse has nowhere to put it. + } + + void CheckVersion() { + if (version != kVersion11 && version != kVersion10) { + throw HTTP1ProtocolError("unsupported HTTP version '" + version + "'"); + } + } + + void AddHeader(std::string name, std::string_view value) { + auto [it, inserted] = headers.try_emplace(std::move(name), std::string(value)); + if (!inserted) { + // Repeated field lines are equivalent to one comma-joined + // value (RFC 9110 §5.3) — except content-length, where + // disagreeing values are a smuggling attempt. + if (it->first == "content-length" && it->second != value) { + throw HTTP1ProtocolError("conflicting content-length values"); + } + if (it->first != "content-length") { + it->second += ", "; + it->second += value; + } + } + } + + void ParseHeaderLine(std::string_view line) { + headerBytes += line.size() + 2; + if (headerBytes > limits.maxHeaderSection) { + throw HTTP1ProtocolError("header section exceeds " + + std::to_string(limits.maxHeaderSection) + " bytes"); + } + if (line.front() == ' ' || line.front() == '\t') { + // obs-fold. RFC 9112 §5.2 allows replacing it with a space + // rather than rejecting the message. + if (lastHeaderName.empty()) throw HTTP1ProtocolError("obs-fold before any header"); + auto it = headers.find(lastHeaderName); + if (it != headers.end()) { + it->second += ' '; + it->second += TrimOWS(line); + } + return; + } + const auto colon = line.find(':'); + if (colon == std::string_view::npos) throw HTTP1ProtocolError("header line has no colon"); + const std::string_view rawName = line.substr(0, colon); + // "No whitespace is allowed between the field name and colon" — + // a recipient MUST reject such a message (RFC 9112 §5.1). + if (!IsToken(rawName)) throw HTTP1ProtocolError("invalid header field name"); + lastHeaderName = ToLowerAscii(rawName); + AddHeader(lastHeaderName, TrimOWS(line.substr(colon + 1))); + } + + std::optional Header(std::string_view name) const { + auto it = headers.find(std::string(name)); + if (it == headers.end()) return std::nullopt; + return std::string_view(it->second); + } + + void ResolveKeepAlive() { + const auto connection = Header("connection"); + const bool close = connection && HasToken(*connection, "close"); + const bool explicitKeep = connection && HasToken(*connection, "keep-alive"); + keepAlive = version == kVersion11 ? !close : (explicitKeep && !close); + } + + void ResolveFraming() { + const auto transferEncoding = Header("transfer-encoding"); + const auto contentLength = Header("content-length"); + if (transferEncoding && contentLength) { + // Ambiguous framing — the classic smuggling primitive. + throw HTTP1ProtocolError("both content-length and transfer-encoding present"); + } + + if (transferEncoding) { + auto codings = SplitCommaList(*transferEncoding); + if (codings.empty() || !EqualsIgnoreCase(codings.back(), "chunked")) { + throw HTTP1ProtocolError("unsupported transfer-encoding '" + + std::string(*transferEncoding) + "'"); + } + if (codings.size() > 1) { + throw HTTP1ProtocolError("stacked transfer-codings are not supported"); + } + bodyMode = BodyMode::Chunked; + return; + } + + if (contentLength) { + if (contentLength->empty() + || !std::ranges::all_of(*contentLength, [](char c) { return c >= '0' && c <= '9'; })) { + throw HTTP1ProtocolError("malformed content-length '" + + std::string(*contentLength) + "'"); + } + std::uint64_t length = 0; + const auto result = std::from_chars(contentLength->data(), + contentLength->data() + contentLength->size(), length); + if (result.ec != std::errc{}) throw HTTP1ProtocolError("content-length out of range"); + if (length > limits.maxBody) { + throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes"); + } + bodyRemaining = length; + bodyMode = length == 0 ? BodyMode::None : BodyMode::Length; + return; + } + + // No explicit framing. A request has no body; a response runs + // to connection close (RFC 9112 §6.3). + if (kind == MessageKind::Request) { + bodyMode = BodyMode::None; + } else { + bodyMode = BodyMode::UntilClose; + keepAlive = false; + } + } + + // Returns false when this was an interim (1xx) response and parsing + // must restart on the next status line. + bool FinishHeaders() { + ResolveKeepAlive(); + + if (kind == MessageKind::Response) { + if (status[0] == '1') { + // Interim response (100 Continue, 103 Early Hints, …). + // It has no body and is not the answer to the request, + // so drop it and keep reading. + headers.clear(); + lastHeaderName.clear(); + status.clear(); + version.clear(); + headerBytes = 0; + state = State::StartLine; + return false; + } + if (status == "204" || status == "304" || headRequest) { + bodyMode = BodyMode::None; + headersDone = true; + state = State::Done; + return true; + } + } else if (auto expect = Header("expect")) { + expectContinue = HasToken(*expect, "100-continue"); + } + + ResolveFraming(); + headersDone = true; + switch (bodyMode) { + case BodyMode::None: state = State::Done; break; + case BodyMode::Length: state = State::Body; break; + case BodyMode::Chunked: state = State::ChunkSize; break; + case BodyMode::UntilClose: state = State::Body; break; + } + return true; + } + + void AppendBody(const char* data, std::size_t size) { + if (body.size() + size > limits.maxBody) { + throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes"); + } + body.append(data, size); + } + + // Drive the state machine as far as the buffered bytes allow, then + // drop what has been consumed so a long-lived connection (or a + // large body arriving in many chunks) doesn't grow the buffer + // without bound. + void Advance() { + AdvanceState(); + if (pos != 0 && pos == buffer.size()) { + buffer.clear(); + pos = 0; + } + } + + void AdvanceState() { + for (;;) { + switch (state) { + case State::StartLine: { + auto line = TryReadLine(limits.maxStartLine); + if (!line) return; + // A server should tolerate stray empty lines left + // over from a previous message (RFC 9112 §2.2). + if (line->empty()) continue; + if (kind == MessageKind::Request) ParseRequestLine(*line); + else ParseStatusLine(*line); + state = State::Headers; + continue; + } + case State::Headers: { + auto line = TryReadLine(limits.maxHeaderSection); + if (!line) return; + if (line->empty()) { + FinishHeaders(); + continue; + } + ParseHeaderLine(*line); + continue; + } + case State::Body: { + const std::size_t available = buffer.size() - pos; + if (bodyMode == BodyMode::UntilClose) { + AppendBody(buffer.data() + pos, available); + pos = buffer.size(); + if (!eof) return; + state = State::Done; + continue; + } + const std::size_t take = static_cast( + std::min(available, bodyRemaining)); + AppendBody(buffer.data() + pos, take); + pos += take; + bodyRemaining -= take; + if (bodyRemaining != 0) { + if (eof) throw HTTP1ProtocolError("connection closed mid-body"); + return; + } + state = State::Done; + continue; + } + case State::ChunkSize: { + auto line = TryReadLine(limits.maxStartLine); + if (!line) return; + // Chunk extensions after ';' are legal and ignorable. + std::string_view digits = TrimOWS(line->substr(0, line->find(';'))); + if (digits.empty()) throw HTTP1ProtocolError("empty chunk size"); + std::uint64_t size = 0; + const auto result = std::from_chars(digits.data(), digits.data() + digits.size(), + size, 16); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size()) { + throw HTTP1ProtocolError("malformed chunk size '" + std::string(digits) + "'"); + } + if (size == 0) { + state = State::Trailers; + continue; + } + chunkRemaining = size; + state = State::ChunkData; + continue; + } + case State::ChunkData: { + const std::size_t available = buffer.size() - pos; + if (available == 0) { + if (eof) throw HTTP1ProtocolError("connection closed mid-chunk"); + return; + } + const std::size_t take = static_cast( + std::min(available, chunkRemaining)); + AppendBody(buffer.data() + pos, take); + pos += take; + chunkRemaining -= take; + if (chunkRemaining != 0) { + if (eof) throw HTTP1ProtocolError("connection closed mid-chunk"); + return; + } + state = State::ChunkTrailingCRLF; + continue; + } + case State::ChunkTrailingCRLF: { + auto line = TryReadLine(2); + if (!line) return; + if (!line->empty()) throw HTTP1ProtocolError("chunk not terminated by CRLF"); + state = State::ChunkSize; + continue; + } + case State::Trailers: { + auto line = TryReadLine(limits.maxHeaderSection); + if (!line) return; + if (line->empty()) { + state = State::Done; + continue; + } + // Trailer fields are merged into the header map; + // callers see one flat view of the message. + ParseHeaderLine(*line); + continue; + } + case State::Done: + return; + } + } + } + + MessageKind kind; + MessageLimits limits; + State state = State::StartLine; + BodyMode bodyMode = BodyMode::None; + + std::string buffer; + std::size_t pos = 0; + bool eof = false; + + std::string method; + std::string path; + std::string scheme; + std::string authority; + std::string version; + std::string status; + std::string lastHeaderName; + std::unordered_map headers; + std::string body; + + std::size_t headerBytes = 0; + std::uint64_t bodyRemaining = 0; + std::uint64_t chunkRemaining = 0; + bool headersDone = false; + bool expectContinue = false; + bool keepAlive = true; + bool headRequest = false; + }; +} diff --git a/interfaces/Crafter.Network-ListenerHTTP1.cppm b/interfaces/Crafter.Network-ListenerHTTP1.cppm new file mode 100644 index 0000000..72f4cbd --- /dev/null +++ b/interfaces/Crafter.Network-ListenerHTTP1.cppm @@ -0,0 +1,76 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Network:ListenerHTTP1; +import std; +import :HTTP; +import :HTTP1; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + // HTTP/1.1 server over plain TCP. Same route map shape as ListenerHTTP, + // so a handler can be registered with both and served over either + // protocol. + // + // Each accepted connection gets its own thread and is served + // sequentially until the peer closes it, `Connection: close` is seen, or + // an idle/read timeout expires. Handlers therefore run concurrently + // across connections but never for two requests on the same one, which + // is what HTTP/1.1 response ordering requires. A dedicated thread rather + // than a ThreadPool task is deliberate: keep-alive connections are idle + // most of their life and would otherwise pin every pool thread. + // + // Implemented: keep-alive and pipelining, content-length and chunked + // request bodies, `Expect: 100-continue`, HEAD (headers only), automatic + // `Date`, and 400/404/500 error responses. Not implemented: TLS, + // CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a + // complete body, so responses are always content-length framed). + export class ListenerHTTP1 { + public: + std::unordered_map> routes; + + // How long a connection may stay idle between requests, and how long + // a single request may take to arrive once started. Both guard + // against a peer holding a thread forever. + std::chrono::milliseconds keepAliveTimeout{15000}; + std::chrono::milliseconds requestTimeout{30000}; + // Limits applied to incoming requests. + HTTP1::MessageLimits limits; + + ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes); + + ~ListenerHTTP1(); + ListenerHTTP1(const ListenerHTTP1&) = delete; + ListenerHTTP1(ListenerHTTP1&&) noexcept; + + // Run the accept loop on the calling thread until Stop(). + void Listen(); + // Stop accepting, close every live connection, and wait for the + // connection threads to finish. + void Stop(); + + // Number of connections currently being served. + std::size_t ConnectionCount() const; + // Connections accepted since construction. + std::uint64_t AcceptedCount() const; + + private: + struct Impl; + std::unique_ptr impl; + }; + + // Runs the accept loop on a background thread so the caller can keep + // going. Mirrors ListenerAsyncHTTP. + export class ListenerAsyncHTTP1 { + public: + ListenerHTTP1 listener; + std::thread thread; + + ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes); + ~ListenerAsyncHTTP1(); + void Stop(); + }; +} +#endif diff --git a/interfaces/Crafter.Network.cppm b/interfaces/Crafter.Network.cppm index efbe32d..3ea16b9 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -17,4 +17,10 @@ export import :WebTransport; // not need the HTTP/3 frame helpers directly. Excluded from the browser // build — HTTP3 uses throw and the wasm target runs with -fno-exceptions. export import :HTTP3; +// HTTP/1.1 over TCP, for peers that are not ready for HTTP/3. Native only: +// in the browser this job is already done by fetch() behind :ClientHTTP, +// and these partitions use exceptions and POSIX sockets. +export import :HTTP1; +export import :ClientHTTP1; +export import :ListenerHTTP1; #endif \ No newline at end of file diff --git a/project.cpp b/project.cpp index 39a92a9..3b0160e 100644 --- a/project.cpp +++ b/project.cpp @@ -7,13 +7,16 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", "interfaces/Crafter.Network-ClientHTTP", "interfaces/Crafter.Network-ListenerHTTP", "interfaces/Crafter.Network-HTTP", + "interfaces/Crafter.Network-HTTP1", + "interfaces/Crafter.Network-ClientHTTP1", + "interfaces/Crafter.Network-ListenerHTTP1", "interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ListenerQUIC", @@ -69,11 +72,13 @@ extern "C" Configuration CrafterBuildProject(std::span a return cfg; } - constexpr std::array networkImplementations = { + constexpr std::array networkImplementations = { "implementations/Crafter.Network-ClientTCP", "implementations/Crafter.Network-ListenerTCP", "implementations/Crafter.Network-ClientHTTP", "implementations/Crafter.Network-ListenerHTTP", + "implementations/Crafter.Network-ClientHTTP1", + "implementations/Crafter.Network-ListenerHTTP1", "implementations/Crafter.Network-ClientQUIC", "implementations/Crafter.Network-ListenerQUIC", "implementations/Crafter.Network-WebTransport", @@ -103,9 +108,9 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array ifaces; + std::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls); @@ -114,8 +119,13 @@ extern "C" Configuration CrafterBuildProject(std::span a // crafter-network static lib via .Dependencies({ &cfg }). if (cfg.target == "x86_64-pc-linux-gnu") { cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); + cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg }); + cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldSend").Dependencies({ &cfg }); + cfg.AddTest("ShouldSendRecieveHTTP1").Dependencies({ &cfg }); + cfg.AddTest("ShouldSendRecieveKeepaliveHTTP1").Dependencies({ &cfg }); + cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg }); diff --git a/tests/ShouldInteropCurlHTTP1/main.cpp b/tests/ShouldInteropCurlHTTP1/main.cpp new file mode 100644 index 0000000..37e700c --- /dev/null +++ b/tests/ShouldInteropCurlHTTP1/main.cpp @@ -0,0 +1,233 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Interop against implementations that are not this library — the whole +// point of shipping HTTP/1.1 in the first place. +// +// * curl drives ListenerHTTP1: keep-alive reuse, chunked upload, +// Expect: 100-continue, HEAD, and a plain GET. +// * ClientHTTP1 drives python3's http.server, which answers HTTP/1.0 with +// `Connection: close` — the legacy shape our own listener never emits. +// +// Both peers are optional: if curl or python3 is missing the corresponding +// half is skipped rather than failed, so the suite still runs on a bare +// machine. + +#include +#include +#include +#include + +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; + } + } + + bool HaveCommand(std::string_view name) { + const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1"; + return std::system(probe.c_str()) == 0; + } + + // Run a command and return its stdout. stderr is folded in so a curl + // failure explains itself in the test output. + std::string Run(const std::string& command) { + std::string output; + FILE* pipe = popen((command + " 2>&1").c_str(), "r"); + if (pipe == nullptr) return output; + char buffer[4096]; + while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) { + output.append(buffer, read); + } + pclose(pipe); + return output; + } + + // A child process, killed when this goes out of scope. + class Child { + public: + explicit Child(std::vector argv) { + std::vector raw; + for (auto& argument : argv) raw.push_back(argument.data()); + raw.push_back(nullptr); + pid = fork(); + if (pid == 0) { + // Keep the test output clean; the child's chatter is not + // interesting unless it fails to start, which shows up as a + // connection failure instead. + freopen("/dev/null", "w", stdout); + freopen("/dev/null", "w", stderr); + execvp(raw[0], raw.data()); + _exit(127); + } + } + ~Child() { + if (pid > 0) { + kill(pid, SIGTERM); + int status = 0; + waitpid(pid, &status, 0); + } + } + Child(const Child&) = delete; + bool Started() const { return pid > 0; } + + private: + pid_t pid = -1; + }; + + // Poll until something accepts on the port, so the test doesn't race a + // slow-starting server. + bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (std::chrono::steady_clock::now() < deadline) { + try { + ClientTCP probe("localhost", port); + return true; + } catch (const std::exception&) { + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } + } + return false; + } + + void CurlAgainstListener() { + if (!HaveCommand("curl")) { + std::println("skipping the curl half: curl is not installed"); + return; + } + + std::unordered_map> routes; + routes["/hello"] = [](const HTTPRequest&) { + return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!"); + }; + routes["/echo"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.method + ":" + request.body); + }; + routes["/agent"] = [](const HTTPRequest& request) { + auto agent = request.headers.find("user-agent"); + return CreateResponseHTTP("200", agent == request.headers.end() ? "none" : agent->second); + }; + + ListenerAsyncHTTP1 listener(8093, std::move(routes)); + Check(WaitForPort(8093, std::chrono::seconds(2)), "the HTTP/1.1 listener came up"); + + const std::string base = "http://localhost:8093"; + + Check(Run("curl -sS --http1.1 " + base + "/hello") == "Hello curl!", "curl GET"); + + // Two URLs in one invocation: curl reuses the connection, which + // only works if our framing let it know the first response ended. + const std::uint64_t before = listener.listener.AcceptedCount(); + const std::string both = Run("curl -sS --http1.1 " + base + "/hello " + base + "/hello"); + Check(both == "Hello curl!Hello curl!", "curl got both responses"); + Check(listener.listener.AcceptedCount() == before + 1, "curl reused one connection for both"); + + Check(Run("curl -sS --http1.1 -d 'body text' " + base + "/echo") == "POST:body text", + "curl POST with content-length"); + + // Chunked upload — curl streams stdin with Transfer-Encoding: + // chunked when it can't know the length up front. + Check(Run("printf 'streamed body' | curl -sS --http1.1 -H 'Transfer-Encoding: chunked' " + "--data-binary @- " + base + "/echo") == "POST:streamed body", + "curl chunked upload"); + + // A body over 1 KiB makes curl wait for `100 Continue` before it + // sends anything. Run it verbosely so the trace proves the interim + // response actually went out — without it curl still recovers after + // a one-second stall, which would hide the bug. + const std::string large(64 * 1024, 'x'); + const std::string upload = + "head -c 65536 /dev/zero | tr '\\0' 'x' | curl -sS --http1.1 " + "-H 'Expect: 100-continue' --data-binary @- " + base + "/echo"; + // The trace goes to stderr and the body to stdout; keeping them in + // separate runs avoids the two streams interleaving in the pipe. + Check(Run(upload + " -v -o /dev/null").find("HTTP/1.1 100 Continue") != std::string::npos, + "curl saw the interim 100 Continue"); + Check(Run(upload) == "POST:" + large, "curl Expect: 100-continue upload arrived intact"); + + // HEAD must produce the GET headers and no body. + const std::string head = Run("curl -sS --http1.1 -I " + base + "/hello"); + Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line"); + Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length"); + Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body"); + + Check(Run("curl -sS --http1.1 -A 'crafter-test/1.0' " + base + "/agent") == "crafter-test/1.0", + "request headers reach the handler"); + + // The status line has to be readable by a real client, not just by + // our own parser. + Check(Run("curl -sS --http1.1 -o /dev/null -w '%{http_code}' " + base + "/missing") == "404", + "curl reads the 404 status"); + + listener.Stop(); + } + + void ClientAgainstPythonServer() { + if (!HaveCommand("python3")) { + std::println("skipping the python half: python3 is not installed"); + return; + } + + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "crafter-network-http1-interop"; + std::filesystem::create_directories(root); + const std::string content = "served by python\n"; + { + std::ofstream file(root / "hello.txt", std::ios::binary); + file << content; + } + + // http.server answers HTTP/1.0 with `Connection: close`: every + // request needs its own connection, and the client has to notice. + Child server({"python3", "-m", "http.server", "8094", "--bind", "127.0.0.1", + "--directory", root.string()}); + Check(server.Started(), "python3 http.server was spawned"); + if (!WaitForPort(8094, std::chrono::seconds(10))) { + std::println("skipping the python half: http.server never came up"); + return; + } + + ClientHTTP1 client("localhost", 8094); + HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8094")); + Check(response.status == "200", "python GET status"); + Check(response.body == content, "python GET body"); + Check(!client.Connected(), "an HTTP/1.0 response closes the connection"); + + HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8094")); + Check(listing.status == "200", "python directory listing status"); + Check(listing.body.find("hello.txt") != std::string::npos, "python directory listing body"); + + HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8094")); + Check(missing.status == "404", "python 404"); + + HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8094")); + Check(head.status == "200", "python HEAD status"); + Check(head.body.empty(), "python HEAD has no body"); + + std::filesystem::remove_all(root); + } +} + +int main() { + try { + CurlAgainstListener(); + ClientAgainstPythonServer(); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + return 1; + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +} diff --git a/tests/ShouldParseHTTP1/main.cpp b/tests/ShouldParseHTTP1/main.cpp new file mode 100644 index 0000000..b7eb700 --- /dev/null +++ b/tests/ShouldParseHTTP1/main.cpp @@ -0,0 +1,294 @@ +//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; +} diff --git a/tests/ShouldSendRecieveHTTP1/main.cpp b/tests/ShouldSendRecieveHTTP1/main.cpp new file mode 100644 index 0000000..400d2fd --- /dev/null +++ b/tests/ShouldSendRecieveHTTP1/main.cpp @@ -0,0 +1,87 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Canonical HTTP/1.1 client/server round-trip: the same shape as +// ShouldSendRecieveHTTP, over TCP instead of QUIC. + +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; + } + } +} + +int main() { + std::unordered_map> routes; + routes["/"] = [](const HTTPRequest&) { + return CreateResponseHTTP("200", "Hello World!"); + }; + routes["/echo"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body); + }; + routes["/query"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.path); + }; + routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse { + throw std::runtime_error("handler exploded"); + }; + + try { + ListenerAsyncHTTP1 listener(8090, std::move(routes)); + ClientHTTP1 client("localhost", 8090); + + HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(hello.status == "200", "GET / status"); + Check(hello.body == "Hello World!", "GET / body"); + Check(hello.headers.contains("date"), "the server stamps a Date header"); + Check(hello.headers.at("content-length") == "12", "content-length matches the body"); + + HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", + std::string("ping pong"))); + Check(echoed.status == "200", "POST /echo status"); + Check(echoed.body == "ping pong", "POST /echo returns the request body"); + Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive"); + + // Query strings must reach the handler intact while still routing on + // the bare path — browsers append them to everything. + HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost")); + Check(query.status == "200", "query-string request routes to the bare path"); + Check(query.body == "/query?a=1&b=2", "the handler sees the full target"); + + HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost")); + Check(missing.status == "404", "unknown route is a 404"); + + // A HEAD gets the headers a GET would produce, and no body. + HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost")); + Check(head.status == "200", "HEAD status"); + Check(head.body.empty(), "HEAD has no body"); + Check(head.headers.at("content-length") == "12", "HEAD still advertises the length"); + + // A throwing handler must become a 500, not a dropped connection. + HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost")); + Check(boom.status == "500", "a throwing handler yields 500"); + Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason"); + + // Every exchange above shared one connection. + Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection"); + + listener.Stop(); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + return 1; + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +} diff --git a/tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp b/tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp new file mode 100644 index 0000000..6164e5c --- /dev/null +++ b/tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp @@ -0,0 +1,89 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// HTTP/1.1 connection reuse. Unlike HTTP/3 — where multiplexing is the +// transport's job — keep-alive here means the same socket carries request +// after request, and both ends have to agree on where each message ends. +// This test pins that down: repeated requests must not open new +// connections, `Connection: close` must end the connection, and the client +// must recover when the server closes a pooled connection under it. + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool condition, std::string_view what) { + if (!condition) { + std::println("FAIL: {}", what); + ++failures; + } + } +} + +int main() { + ThreadPool::Start(); + + std::unordered_map> routes; + routes["/"] = [](const HTTPRequest&) { + return CreateResponseHTTP("200", "Hello World!"); + }; + routes["/once"] = [](const HTTPRequest&) { + // A handler that asks for the connection to end after this reply. + return CreateResponseHTTP("200", {{"connection", "close"}}, "bye"); + }; + + try { + ListenerAsyncHTTP1 listener(8091, std::move(routes)); + ClientHTTP1 client("localhost", 8091); + + for (int i = 0; i < 5; ++i) { + HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(response.status == "200", "keep-alive request status"); + Check(response.body == "Hello World!", "keep-alive request body"); + Check(client.Connected(), "the client keeps the socket between requests"); + } + Check(listener.listener.AcceptedCount() == 1, "five requests shared one connection"); + + // A handler that answers with `connection: close` ends the + // connection after its response; the client must notice and dial + // again for the next request instead of writing into a dead socket. + HTTPResponse closing = client.Send(CreateRequestHTTP("GET", "/once", "localhost")); + Check(closing.status == "200", "close-flagged response still arrives"); + Check(closing.body == "bye", "close-flagged response body"); + Check(!client.Connected(), "the client drops a connection the server closed"); + + HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(after.body == "Hello World!", "the next request redials transparently"); + Check(listener.listener.AcceptedCount() == 2, "exactly one extra connection was opened"); + + // Force the stale-connection race that HTTP/1.1 keep-alive cannot + // avoid: the server goes away while a pooled connection looks + // usable. The client is expected to replay the request on a fresh + // connection rather than surface the error. + Check(client.Connected(), "a connection is pooled before the restart"); + listener.Stop(); + ListenerAsyncHTTP1 restarted(8091, {{"/", [](const HTTPRequest&) { + return CreateResponseHTTP("200", "Hello Again!"); + }}}); + HTTPResponse recovered = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(recovered.body == "Hello Again!", "the client recovers from a stale pooled connection"); + Check(restarted.listener.AcceptedCount() == 1, "recovery dialled the new listener"); + + restarted.Stop(); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + ThreadPool::Stop(); + return 1; + } + + ThreadPool::Stop(); + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +} diff --git a/tests/ShouldSendRecieveLargeHTTP1/main.cpp b/tests/ShouldSendRecieveLargeHTTP1/main.cpp new file mode 100644 index 0000000..b2831b6 --- /dev/null +++ b/tests/ShouldSendRecieveLargeHTTP1/main.cpp @@ -0,0 +1,74 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A body far larger than any socket buffer, in both directions. This is +// where short writes, partial reads and off-by-one framing show up: the +// request has to survive being split across dozens of send() calls and the +// response across dozens of recv() calls. + +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; + } + } + + // Non-uniform payload, so a mangled offset can't accidentally compare + // equal the way a run of identical bytes would. + std::string MakePayload(std::size_t size) { + std::string payload(size, '\0'); + for (std::size_t i = 0; i < size; ++i) { + payload[i] = static_cast('A' + (i * 7 + i / 251) % 26); + } + return payload; + } +} + +int main() { + constexpr std::size_t payloadSize = 10 * 1024 * 1024; + const std::string payload = MakePayload(payloadSize); + + std::unordered_map> routes; + routes["/echo"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.body); + }; + routes["/size"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", std::to_string(request.body.size())); + }; + + try { + ListenerAsyncHTTP1 listener(8092, std::move(routes)); + ClientHTTP1 client("localhost", 8092); + + HTTPResponse size = client.Send(CreateRequestHTTP("POST", "/size", "localhost", payload)); + Check(size.status == "200", "large POST status"); + Check(size.body == std::to_string(payloadSize), "the server received every byte"); + + // Same again in both directions, and on the same connection, so a + // leftover byte from the previous exchange would desynchronise the + // framing and show up here. + HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", payload)); + Check(echoed.status == "200", "large echo status"); + Check(echoed.body.size() == payloadSize, "the response body is the right length"); + Check(echoed.body == payload, "the response body is byte-for-byte the request"); + Check(listener.listener.AcceptedCount() == 1, "both transfers shared one connection"); + + listener.Stop(); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + return 1; + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +}