//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; }