#include "nocal/sync/curl_http.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using nocal::sync::CurlHttpError; using nocal::sync::CurlHttpOptions; using nocal::sync::CurlHttpTransport; using nocal::sync::HttpHeader; using nocal::sync::HttpMethod; using nocal::sync::HttpRequest; using nocal::sync::HttpResponse; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string(message)); } } void close_socket(int descriptor) { if (descriptor >= 0) { ::close(descriptor); } } void send_all(int descriptor, std::string_view data) { std::size_t sent = 0; while (sent < data.size()) { const ssize_t count = ::send( descriptor, data.data() + sent, data.size() - sent, MSG_NOSIGNAL); if (count <= 0) { return; } sent += static_cast(count); } } [[nodiscard]] std::size_t content_length(std::string_view headers) { std::string lower(headers); std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char character) { return character >= 'A' && character <= 'Z' ? static_cast(character + ('a' - 'A')) : static_cast(character); }); const std::string_view name = "\r\ncontent-length:"; const std::size_t position = lower.find(name); if (position == std::string::npos) { return 0; } std::size_t offset = position + name.size(); while (offset < lower.size() && (lower[offset] == ' ' || lower[offset] == '\t')) { ++offset; } std::size_t length = 0; while (offset < lower.size() && lower[offset] >= '0' && lower[offset] <= '9') { length = length * 10U + static_cast(lower[offset] - '0'); ++offset; } return length; } [[nodiscard]] std::string receive_request(int descriptor) { std::string request; std::array buffer{}; std::size_t expected = std::string::npos; while (true) { const ssize_t count = ::recv(descriptor, buffer.data(), buffer.size(), 0); if (count <= 0) { break; } request.append(buffer.data(), static_cast(count)); const std::size_t end = request.find("\r\n\r\n"); if (end != std::string::npos && expected == std::string::npos) { expected = end + 4 + content_length(std::string_view(request).substr(0, end + 2)); } if (expected != std::string::npos && request.size() >= expected) { break; } } return request; } class OneShotServer { public: using Handler = std::function; explicit OneShotServer(Handler handler) { listener_ = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); require(listener_ >= 0, "unable to create test server socket"); const int enabled = 1; require(::setsockopt(listener_, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == 0, "unable to configure test server socket"); sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = 0; require(::bind(listener_, reinterpret_cast(&address), sizeof(address)) == 0, "unable to bind test server socket"); socklen_t size = sizeof(address); require(::getsockname(listener_, reinterpret_cast(&address), &size) == 0, "unable to inspect test server socket"); port_ = ntohs(address.sin_port); require(::listen(listener_, 4) == 0, "unable to listen on test server socket"); thread_ = std::thread([this, handler = std::move(handler)] { try { pollfd descriptor{listener_, POLLIN, 0}; const int ready = ::poll(&descriptor, 1, 2'000); if (ready > 0) { const int connection = ::accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC); if (connection >= 0) { ++connections_; handler(connection); close_socket(connection); } } } catch (...) { error_ = std::current_exception(); } }); } ~OneShotServer() { if (thread_.joinable()) { thread_.join(); } close_socket(listener_); } OneShotServer(const OneShotServer&) = delete; OneShotServer& operator=(const OneShotServer&) = delete; [[nodiscard]] std::string url(std::string_view path = "/") const { return "http://127.0.0.1:" + std::to_string(port_) + std::string(path); } [[nodiscard]] std::string localhost_url(std::string_view path = "/") const { return "http://localhost:" + std::to_string(port_) + std::string(path); } void finish() { if (thread_.joinable()) { thread_.join(); } if (error_) { std::rethrow_exception(error_); } } [[nodiscard]] int connections() const noexcept { return connections_; } private: int listener_{-1}; unsigned short port_{0}; std::thread thread_; std::exception_ptr error_; int connections_{0}; }; template CurlHttpError expect_curl_error(Action&& action, CurlHttpError::Kind kind) { try { action(); } catch (const CurlHttpError& error) { if (error.kind() != kind) { throw std::runtime_error("curl error had kind " + std::to_string(static_cast(error.kind())) + " instead of " + std::to_string(static_cast(kind))); } return error; } throw std::runtime_error("expected CurlHttpError was not thrown"); } [[nodiscard]] std::string method_text(HttpMethod method) { switch (method) { case HttpMethod::get: return "GET"; case HttpMethod::post: return "POST"; case HttpMethod::put: return "PUT"; case HttpMethod::patch: return "PATCH"; case HttpMethod::delete_: return "DELETE"; } throw std::runtime_error("unknown test method"); } void test_methods_headers_binary_body_status_and_response_headers() { for (const HttpMethod method : {HttpMethod::get, HttpMethod::post, HttpMethod::put, HttpMethod::patch, HttpMethod::delete_}) { std::string received; OneShotServer server([&](int connection) { received = receive_request(connection); send_all(connection, "HTTP/1.1 207 Multi-Status\r\nX-Order: first\r\n" "X-Order: second\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok!"); }); CurlHttpTransport transport; const std::string body{"A\0B", 3}; const HttpResponse response = transport.send( {method, server.url("/method"), {{"X-Test", "value"}}, body}); server.finish(); require(server.connections() == 1, "transport opened more than one connection"); require(received.starts_with(method_text(method) + " /method HTTP/1.1\r\n"), "transport sent the wrong HTTP method"); require(received.find("\r\nX-Test: value\r\n") != std::string::npos, "transport did not preserve request header"); require(received.size() >= body.size() && received.compare(received.size() - body.size(), body.size(), body) == 0, "transport did not preserve binary request body"); require(response.status == 207 && response.body == "ok!", "transport did not preserve response status/body"); require(std::count(response.headers.begin(), response.headers.end(), HttpHeader{"X-Order", "first"}) == 1 && std::count(response.headers.begin(), response.headers.end(), HttpHeader{"X-Order", "second"}) == 1, "transport did not preserve ordinary duplicate response headers"); } } void test_redirect_is_not_followed() { OneShotServer server([](int connection) { (void)receive_request(connection); send_all(connection, "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/never\r\n" "Content-Length: 0\r\nConnection: close\r\n\r\n"); }); CurlHttpTransport transport; const HttpResponse response = transport.send({HttpMethod::get, server.url(), {}, {}}); server.finish(); require(response.status == 302 && server.connections() == 1, "transport followed or retried a redirect"); } void test_localhost_resolution_remains_loopback() { OneShotServer server([](int connection) { (void)receive_request(connection); send_all(connection, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); }); CurlHttpTransport transport; const HttpResponse response = transport.send({HttpMethod::get, server.localhost_url(), {}, {}}); server.finish(); require(response.status == 200 && response.body == "ok" && server.connections() == 1, "localhost did not resolve through the loopback-only socket policy"); } void test_cumulative_response_limit() { OneShotServer server([](int connection) { (void)receive_request(connection); send_all(connection, "HTTP/1.1 200 OK\r\nContent-Length: 256\r\nConnection: close\r\n\r\n" + std::string(256, 'x')); }); CurlHttpOptions options; options.maximum_response_bytes = 100; CurlHttpTransport transport(options); expect_curl_error( [&] { (void)transport.send({HttpMethod::get, server.url(), {}, {}}); }, CurlHttpError::Kind::response_too_large); server.finish(); require(server.connections() == 1, "oversize response caused a retry"); } void test_timeout_is_single_and_sanitized() { OneShotServer server([](int connection) { (void)receive_request(connection); std::this_thread::sleep_for(std::chrono::milliseconds(150)); send_all(connection, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); }); CurlHttpOptions options; options.connect_timeout_milliseconds = 40; options.total_timeout_milliseconds = 40; CurlHttpTransport transport(options); const CurlHttpError error = expect_curl_error( [&] { (void)transport.send({HttpMethod::get, server.url("/?SECRET_QUERY"), {{"X-Secret", "SECRET_HEADER"}}, "SECRET_BODY"}); }, CurlHttpError::Kind::transport_failed); server.finish(); const std::string message = error.what(); require(message.find("SECRET") == std::string::npos && message.find("127.0.0.1") == std::string::npos, "transport error leaked request details"); require(server.connections() == 1, "timed-out request was retried"); } void test_invalid_options() { std::vector invalid; CurlHttpOptions value; value.connect_timeout_milliseconds = 0; invalid.push_back(value); value = {}; value.total_timeout_milliseconds = -1; invalid.push_back(value); value = {}; value.maximum_response_bytes = 0; invalid.push_back(value); const std::vector invalid_agents = {std::string("bad\ragent"), std::string("bad\nagent"), std::string("bad\0agent", 9), std::string("bad\x7f")}; for (const std::string& agent : invalid_agents) { value = {}; value.user_agent = agent; invalid.push_back(value); } for (const CurlHttpOptions& options : invalid) { expect_curl_error( [&] { CurlHttpTransport transport(options); }, CurlHttpError::Kind::invalid_request); } } void test_invalid_headers_and_urls_are_rejected_before_network() { CurlHttpTransport transport; const std::vector invalid_headers = {{"", "value"}, {"Bad Name", "value"}, {"Bad:Name", "value"}, {"X-Test", "line\rbreak"}, {"X-Test", "line\nbreak"}, {"X-Test", std::string("nul\0break", 9)}}; for (std::size_t index = 0; index < invalid_headers.size(); ++index) { try { expect_curl_error( [&] { (void)transport.send({HttpMethod::get, "http://127.0.0.1:1/", {invalid_headers[index]}, "SECRET_BODY"}); }, CurlHttpError::Kind::invalid_request); } catch (const std::exception& error) { throw std::runtime_error( "invalid header case " + std::to_string(index) + ": " + error.what()); } } const std::vector invalid_urls = {"", "/relative", "ftp://example.test/", "http://example.test/", "http://localhost.evil/", "http://user@localhost/", "http://::1/", "http://[::1]evil/", "https:///missing", "http://localhost:0/", "http://localhost:65536/", "http://localhost/a path", "https://[not-ipv6]/", "https://bad%host/", std::string("http://localhost/a\nb")}; for (std::size_t index = 0; index < invalid_urls.size(); ++index) { try { const CurlHttpError error = expect_curl_error( [&] { (void)transport.send( {HttpMethod::get, invalid_urls[index], {}, "SECRET_BODY"}); }, CurlHttpError::Kind::invalid_request); require(std::string(error.what()).find("SECRET") == std::string::npos, "invalid-request error leaked request data"); } catch (const std::exception& error) { throw std::runtime_error( "invalid URL case " + std::to_string(index) + ": " + error.what()); } } } void test_parallel_construction() { std::mutex mutex; std::exception_ptr error; std::vector threads; for (int index = 0; index < 8; ++index) { threads.emplace_back([&] { try { CurlHttpTransport transport; } catch (...) { std::scoped_lock lock(mutex); error = std::current_exception(); } }); } for (std::thread& thread : threads) { thread.join(); } if (error) { std::rethrow_exception(error); } } } // namespace int main() { try { test_methods_headers_binary_body_status_and_response_headers(); test_redirect_is_not_followed(); test_localhost_resolution_remains_loopback(); test_cumulative_response_limit(); test_timeout_is_single_and_sanitized(); test_invalid_options(); test_invalid_headers_and_urls_are_rejected_before_network(); test_parallel_construction(); } catch (const std::exception& error) { std::cerr << "curl HTTP tests failed: " << error.what() << '\n'; return 1; } std::cout << "curl HTTP tests passed\n"; return 0; }