233 lines
9.5 KiB
C++
233 lines
9.5 KiB
C++
|
|
//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 <signal.h>
|
||
|
|
#include <unistd.h>
|
||
|
|
#include <sys/wait.h>
|
||
|
|
#include <stdio.h>
|
||
|
|
|
||
|
|
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<std::string> argv) {
|
||
|
|
std::vector<char*> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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;
|
||
|
|
}
|