Crafter.Network/interfaces/Crafter.Network-HTTP1.cppm

789 lines
35 KiB
Text
Raw Normal View History

feat(http1): add an HTTP/1.1 client and listener HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:45:09 +00:00
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// 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<char>(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<unsigned char>(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<char>(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<std::string_view> SplitCommaList(std::string_view value) {
std::vector<std::string_view> 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<std::string_view, std::string_view> 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<std::chrono::seconds>(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<std::string, std::string>& 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");
}
feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3 listener or a terminating proxy in front. Neither helps the callers this stack exists for — curl scripts, CI tooling, old proxies — so wrap the transport in libssl instead. Two new partitions: :Stream a ByteStream with per-call deadlines on both directions, plus the plaintext socket implementation. The HTTP/1.1 client and listener now hold a ByteStream& and never learn which transport they have, which is what lets one code path serve both schemes. :TLS TLSContext/TLSStream over OpenSSL 3, with credentials for both roles: chain and hostname verification on by default, private trust anchors, client certificates, mutual TLS, ALPN, and an in-process self-signed certificate for development. Both descriptors go non-blocking and every read and write is driven by poll() against a deadline. That is required for TLS — a blocking descriptor cannot express a handshake timeout — and it means a plaintext write can now time out too, instead of parking forever against a peer that stopped reading. ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the existing ones still speak http://. The listener handshakes on the connection's own thread, so a peer that stalls mid-handshake costs one thread rather than the accept loop, and a failed handshake is counted rather than logged — on a public port it is ordinary traffic. MessageParser gains SetDefaultScheme so origin-form targets report the scheme the transport actually used; handlers shared with ListenerHTTP now see the same "https" they would over HTTP/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:13:30 +00:00
// Scheme reported for origin-form request targets, which carry none of
// their own. Only the transport knows — "https" once TLS is
// terminating the connection. An absolute-form target still wins, as
// it names its own scheme. Survives Reset().
void SetDefaultScheme(std::string scheme) {
defaultScheme = std::move(scheme);
}
feat(http1): add an HTTP/1.1 client and listener HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:45:09 +00:00
void Feed(const char* data, std::size_t size) {
if (size != 0) buffer.append(data, size);
Advance();
}
void Feed(std::span<const char> 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);
feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3 listener or a terminating proxy in front. Neither helps the callers this stack exists for — curl scripts, CI tooling, old proxies — so wrap the transport in libssl instead. Two new partitions: :Stream a ByteStream with per-call deadlines on both directions, plus the plaintext socket implementation. The HTTP/1.1 client and listener now hold a ByteStream& and never learn which transport they have, which is what lets one code path serve both schemes. :TLS TLSContext/TLSStream over OpenSSL 3, with credentials for both roles: chain and hostname verification on by default, private trust anchors, client certificates, mutual TLS, ALPN, and an in-process self-signed certificate for development. Both descriptors go non-blocking and every read and write is driven by poll() against a deadline. That is required for TLS — a blocking descriptor cannot express a handshake timeout — and it means a plaintext write can now time out too, instead of parking forever against a peer that stopped reading. ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the existing ones still speak http://. The listener handshakes on the connection's own thread, so a peer that stalls mid-handshake costs one thread rather than the accept loop, and a failed handshake is counted rather than logged — on a public port it is ordinary traffic. MessageParser gains SetDefaultScheme so origin-form targets report the scheme the transport actually used; handlers shared with ListenerHTTP now see the same "https" they would over HTTP/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:13:30 +00:00
request.scheme = scheme.empty() ? defaultScheme : std::move(scheme);
feat(http1): add an HTTP/1.1 client and listener HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:45:09 +00:00
// 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<std::string_view> 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<std::string_view> 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::size_t>(
std::min<std::uint64_t>(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::size_t>(
std::min<std::uint64_t>(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;
feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3 listener or a terminating proxy in front. Neither helps the callers this stack exists for — curl scripts, CI tooling, old proxies — so wrap the transport in libssl instead. Two new partitions: :Stream a ByteStream with per-call deadlines on both directions, plus the plaintext socket implementation. The HTTP/1.1 client and listener now hold a ByteStream& and never learn which transport they have, which is what lets one code path serve both schemes. :TLS TLSContext/TLSStream over OpenSSL 3, with credentials for both roles: chain and hostname verification on by default, private trust anchors, client certificates, mutual TLS, ALPN, and an in-process self-signed certificate for development. Both descriptors go non-blocking and every read and write is driven by poll() against a deadline. That is required for TLS — a blocking descriptor cannot express a handshake timeout — and it means a plaintext write can now time out too, instead of parking forever against a peer that stopped reading. ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the existing ones still speak http://. The listener handshakes on the connection's own thread, so a peer that stalls mid-handshake costs one thread rather than the accept loop, and a failed handshake is counted rather than logged — on a public port it is ordinary traffic. MessageParser gains SetDefaultScheme so origin-form targets report the scheme the transport actually used; handlers shared with ListenerHTTP now see the same "https" they would over HTTP/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:13:30 +00:00
// Not cleared by Reset(): the transport does not change under a
// connection, so it is told to the parser once and applies to every
// request on it.
std::string defaultScheme = "http";
feat(http1): add an HTTP/1.1 client and listener HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:45:09 +00:00
std::string authority;
std::string version;
std::string status;
std::string lastHeaderName;
std::unordered_map<std::string, std::string> 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;
};
}