From 9e6bcea4b8ecad1e1b9db05926ad274eb0294b4d Mon Sep 17 00:00:00 2001 From: AgentDudu Date: Tue, 20 Oct 2026 17:07:20 +0700 Subject: [PATCH] feat: static file handler with traversal guard, write cap, slow-client tests StaticFiles: strict percent-decode before resolution; traversal/encoded/backslash/absolute refused 404 pre-disk; weakly_canonical symlink containment, case-folded on Windows; index, MIME, 405, HEAD honest Content-Length. Handler now returns Reply{status,content_type,body}; handler exceptions become 500 at the reactor seam. Slow-client limits: per-connection max_queued_bytes caps the output buffer; a throttle stops reading (real flow control), POLLOUT or next read resumes the drain. Slow-loris partial head -> 408 test; 10-request pipelined burst through a 100-byte cap test. Test harness: readResponse now honors carry-over of coalesced pipelined responses (recv-after-check, not recv-before-check). Container smoke serves a scratch root and asserts traversal 404. Tree-wide clang-format applied (175 latent violations; clang-format 23.1.2 now on PATH); ck.ps1 format gate is live. --- CMakeLists.txt | 6 +- PLAN.md | 6 +- docs/ARCHITECTURE.md | 17 ++- src/app/signals.h | 4 +- src/app/signals_posix.cpp | 6 +- src/app/signals_win.cpp | 12 +- src/http/headers.cpp | 3 +- src/http/headers.h | 4 +- src/http/request.cpp | 8 +- src/http/request.h | 8 +- src/http/response.cpp | 4 +- src/http/response.h | 8 +- src/http/status.cpp | 4 +- src/main.cpp | 14 +-- src/net/context.h | 4 +- src/net/detail_raw.h | 6 +- src/net/poll.h | 8 +- src/net/raw_posix.cpp | 8 +- src/net/raw_win.cpp | 12 +- src/net/socket.h | 4 +- src/net/socket_common.cpp | 10 +- src/server/connection.cpp | 145 +++++++++++++++++------- src/server/connection.h | 29 +++-- src/server/handler.h | 21 ++++ src/server/server.cpp | 22 ++-- src/server/server.h | 16 +-- src/server/shutdown.h | 2 +- src/server/static_file.cpp | 226 +++++++++++++++++++++++++++++++++++++ src/server/static_file.h | 37 ++++++ src/util/time.h | 9 +- tests/test_main.cpp | 4 +- tests/test_request.cpp | 19 ++-- tests/test_server.cpp | 144 ++++++++++++++++++----- tests/test_static.cpp | 138 ++++++++++++++++++++++ tools/asan-inside.sh | 6 +- 35 files changed, 798 insertions(+), 176 deletions(-) create mode 100644 src/server/handler.h create mode 100644 src/server/static_file.cpp create mode 100644 src/server/static_file.h create mode 100644 tests/test_static.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 00687d7..766216f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,7 +71,8 @@ target_compile_options(net PRIVATE ${HTTP_WARNINGS}) # server: reactor + connection, platform-neutral; signals implementation is per OS. add_library(server STATIC src/server/server.cpp - src/server/connection.cpp) + src/server/connection.cpp + src/server/static_file.cpp) target_link_libraries(server PUBLIC net http) target_compile_options(server PRIVATE ${HTTP_WARNINGS}) @@ -92,7 +93,8 @@ add_executable(unit_tests tests/test_socket.cpp tests/test_request.cpp tests/test_response.cpp - tests/test_server.cpp) + tests/test_server.cpp + tests/test_static.cpp) target_include_directories(unit_tests SYSTEM PRIVATE external/doctest) # doctest is a vendored header: warnings from it are not ours to fix, # hence SYSTEM above; our own test code still builds warning-clean. diff --git a/PLAN.md b/PLAN.md index cdf2d91..e8a4a00 100644 --- a/PLAN.md +++ b/PLAN.md @@ -175,13 +175,13 @@ phase/step/milestone wording, ever. | C | `feature/ci-matrix` | Actions matrix: Linux job = sanitizer container (`tools/asan-inside.sh`), Windows = native MinGW + ctest | merged — PR #5 gated itself green on first run | | R | `feature/reactor-and-connection` | single-threaded poll reactor: connection state machine, timeouts (408), POLLOUT backpressure, signals (self-pipe / console ctrl), graceful drain | merged — 200/keep-alive/408/400/stop integration tests; ASan + TSan green; SIGINT-exit-0 smoke in the Linux gate | | T | `feature/reactor-threading` | acceptor thread + K reactors + hand-off inbox; `Connection` stays owned by one reactor | TSan green on concurrent tests; RSS stays flat | -| S | `feature/static-files` | root mapping, traversal guard, MIME, index, 404/405, error pages (`std::filesystem`) | status-by-status integration tests; traversal rejected on both OSes | +| ✓ | ~~`feature/static-files`~~ | root mapping, traversal guard, MIME, index, 404/405, HEAD (`std::filesystem`) + per-connection write cap | merged — traversal/HEAD/405/slow-loris/pipelined-burst tests both OSes; ASan + TSan green | | G | `feature/caching-and-range` | ETag, Last-Modified, If-None-Match, Range/206/416 | 304 + 206 tests incl. multi-range rejection | -| H | `feature/hardening` | pipelining, slow-body limit, max-body/max-heads, request-smuggling corpus, soak | pipelined + slow-client tests; ASan clean; 60 s soak flat RSS on Linux | +| H | `feature/hardening` | slow-body limit, smuggling corpus, soak (pipelining + slow-client tests landed with S) | ASan clean; 60 s soak flat RSS on Linux | | D | `feature/docs-and-benchmark` | README (build/run per OS, limits), load script, numbers | documented; req/s + RSS recorded on both platforms | Then `release/0.1.0` → `develop` + PR to `main` (approval required) → tag `v0.1.0`. -R merged single-threaded; S (static files) stacks on it — T (threading) can land after S. +R merged single-threaded; S (static files) merged on top — T (threading) can land next. ## 8. Git workflow (gitflow) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 74fb520..0d474a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,11 +20,20 @@ until then. - `http/` — status, headers, request parser, response serialization landed. Parser is push/incremental, enforces head/body/count limits, and rejects bare-LF endings, obsolete folding, duplicate `Content-Length`, `Content-Length`+`Transfer-Encoding`, - non-token names/methods, unknown versions. Pending: URL decode/normalise, MIME. + non-token names/methods, unknown versions. URL decode/normalise and MIME live + with the static handler (they are a serving concern, not a parsing one). - `server/` — single-threaded poll reactor (`Server::run`): accept batch, per-connection - deadlines (408), backpressure via POLLOUT, graceful stop (drain queued responses, - grace cap, then drop). `Connection` = socket + `RequestParser` + output buffer; - protocol errors are answered once (best effort) then the connection drops — + deadlines (408), graceful stop (drain queued responses, grace cap, then drop). + `Connection` = socket + `RequestParser` + capped output buffer (`max_queued_bytes`): + a slow reader throttles the drain — queued responses stop growing, remaining + pipelined requests wait in the parser, POLLOUT or the next read resumes it. + Handlers return `Reply{status, content_type, body}`; HEAD keeps an honest + `Content-Length` and no body; handler exceptions become 500 at the reactor seam. + `StaticFiles` serves a canonical root: strict percent-decode before resolution, + traversal/encoded/backslash/absolute targets refused as 404 before touching disk + (symlink prefixes resolved via `weakly_canonical`, case-folded containment on + Windows), index.html for directories, 405 elsewhere, whole-file reads. + Protocol errors are answered once (best effort) then the connection drops — smuggling candidates never share a stream. `ShutdownSignal` is the pluggable stop trigger; tests drive it directly. - `app/` — shutdown signals, one paragraph precisely because behaviour differs: diff --git a/src/app/signals.h b/src/app/signals.h index e5382bb..c3400f4 100644 --- a/src/app/signals.h +++ b/src/app/signals.h @@ -13,7 +13,7 @@ namespace app { // Windows: the handler sets a flag and blocks (a returning handler means "handled, // exit now" would kill the graceful drain); the reactor notices within pollCap(). class ShutdownSignals final : public server::ShutdownSignal { - public: +public: struct Impl; // OS state, defined in the per-OS .cpp ShutdownSignals(); @@ -25,7 +25,7 @@ class ShutdownSignals final : public server::ShutdownSignal { std::chrono::milliseconds pollCap() const override; void notifyStopped() override; - private: +private: std::unique_ptr impl_; }; diff --git a/src/app/signals_posix.cpp b/src/app/signals_posix.cpp index b6b15d4..18d179f 100644 --- a/src/app/signals_posix.cpp +++ b/src/app/signals_posix.cpp @@ -37,7 +37,7 @@ ShutdownSignals::ShutdownSignals() : impl_(std::make_unique()) { return; // degraded: reactor still re-checks after every poll } g_impl = impl_.get(); - struct sigaction sa {}; + struct sigaction sa{}; sa.sa_handler = onSignal; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; // send/poll EINTR handling exists, but avoid churn @@ -53,7 +53,9 @@ ShutdownSignals::~ShutdownSignals() { } } -bool ShutdownSignals::stopRequested() const { return g_stop.load(); } +bool ShutdownSignals::stopRequested() const { + return g_stop.load(); +} void ShutdownSignals::preparePoll(net::PollSet& set) { if (impl_->pipe[0] >= 0) { diff --git a/src/app/signals_win.cpp b/src/app/signals_win.cpp index 1449889..87d9a11 100644 --- a/src/app/signals_win.cpp +++ b/src/app/signals_win.cpp @@ -48,10 +48,16 @@ ShutdownSignals::~ShutdownSignals() { } } -bool ShutdownSignals::stopRequested() const { return g_stop.load(); } +bool ShutdownSignals::stopRequested() const { + return g_stop.load(); +} -void ShutdownSignals::preparePoll(net::PollSet& set) { (void)set; } -void ShutdownSignals::afterPoll(const net::PollSet& set) { (void)set; } +void ShutdownSignals::preparePoll(net::PollSet& set) { + (void)set; +} +void ShutdownSignals::afterPoll(const net::PollSet& set) { + (void)set; +} std::chrono::milliseconds ShutdownSignals::pollCap() const { return std::chrono::milliseconds(100); // no waitable socket+event union on WSAPoll diff --git a/src/http/headers.cpp b/src/http/headers.cpp index af5e9b3..8827541 100644 --- a/src/http/headers.cpp +++ b/src/http/headers.cpp @@ -27,8 +27,7 @@ bool isTokenChar(char c) { // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / // "_" / "`" / "|" / "~" / DIGIT / ALPHA static const char* extras = "!#$%&'*+-.^_`|~"; - return std::isalnum(static_cast(c)) != 0 || - std::strchr(extras, c) != nullptr; + return std::isalnum(static_cast(c)) != 0 || std::strchr(extras, c) != nullptr; } std::string trimField(std::string value) { diff --git a/src/http/headers.h b/src/http/headers.h index dc22bbc..d76be31 100644 --- a/src/http/headers.h +++ b/src/http/headers.h @@ -14,7 +14,7 @@ bool validFieldValue(std::string_view value); // Ordered header list with case-insensitive lookup. Values are trimmed of // leading/trailing SP and HTAB on insertion (RFC 7230 obs-fold removal semantics). class Headers { - public: +public: struct Entry { std::string name; std::string value; @@ -26,7 +26,7 @@ class Headers { bool contains(std::string_view name) const { return get(name) != nullptr; } const std::vector& entries() const { return entries_; } - private: +private: std::size_t find(std::string_view name) const; std::vector entries_; diff --git a/src/http/request.cpp b/src/http/request.cpp index 1f6050d..00074e7 100644 --- a/src/http/request.cpp +++ b/src/http/request.cpp @@ -6,7 +6,9 @@ namespace http { namespace { -bool isOWS(char c) { return c == ' ' || c == '\t'; } +bool isOWS(char c) { + return c == ' ' || c == '\t'; +} // Value bytes: printable, tab, or extended (obs); never CR/LF/NUL/other CTL. bool isValueChar(char c) { @@ -29,7 +31,9 @@ bool equalIgnoreCase(std::string_view a, std::string_view b) { } // namespace -void RequestParser::feed(std::string_view data) { buf_.append(data); } +void RequestParser::feed(std::string_view data) { + buf_.append(data); +} void RequestParser::fail(Error e) { error_ = e; diff --git a/src/http/request.h b/src/http/request.h index 19cb72b..4b7458a 100644 --- a/src/http/request.h +++ b/src/http/request.h @@ -24,7 +24,7 @@ struct Request { // obsolete header folding, duplicate Content-Length, Content-Length together // with Transfer-Encoding, non-token method/name characters, unknown versions. class RequestParser { - public: +public: struct Limits { std::size_t maxHeadBytes = 64 * 1024; std::size_t maxBodyBytes = 8 * 1024 * 1024; @@ -60,7 +60,7 @@ class RequestParser { std::size_t bufferedBytes() const { return buf_.size() - pos_; } - private: +private: enum class State { RequestLine, Headers, Body, Done, Failed }; bool parseRequestLine(); @@ -70,8 +70,8 @@ class RequestParser { Limits limits_; std::string buf_; - std::size_t pos_ = 0; // first unparsed byte (request line / header scan cursor) - std::size_t scan_ = 0; // offset already searched for the header terminator + std::size_t pos_ = 0; // first unparsed byte (request line / header scan cursor) + std::size_t scan_ = 0; // offset already searched for the header terminator std::size_t head_start_ = 0; // first byte of the current request line State state_ = State::RequestLine; Error error_ = Error::None; diff --git a/src/http/response.cpp b/src/http/response.cpp index 9440494..15b6006 100644 --- a/src/http/response.cpp +++ b/src/http/response.cpp @@ -35,8 +35,8 @@ bool Response::serializeHead(std::string& out) const { return false; // RFC 7230: 204/304/1xx must not carry a body } - std::string head = "HTTP/1.1 " + std::to_string(code) + " " + - std::string(reasonPhrase(status_)) + "\r\n"; + std::string head = + "HTTP/1.1 " + std::to_string(code) + " " + std::string(reasonPhrase(status_)) + "\r\n"; bool haveLength = headers_.get("Content-Length") != nullptr; for (const auto& entry : headers_.entries()) { diff --git a/src/http/response.h b/src/http/response.h index 2c46e7f..416ca12 100644 --- a/src/http/response.h +++ b/src/http/response.h @@ -13,7 +13,7 @@ namespace http { // Unless Transfer-Encoding declares chunked, a Content-Length is implied by the // body and written automatically when not set explicitly. class Response { - public: +public: explicit Response(Status status = Status::OK) : status_(status) {} Status status() const { return status_; } @@ -36,7 +36,7 @@ class Response { // Head + body. Empty string if head serialization fails. std::string serialize() const; - private: +private: bool chunked() const; Status status_; @@ -47,6 +47,8 @@ class Response { // chunk-size (lowercase hex) CRLF data CRLF std::string encodeChunk(std::string_view data); // The zero-size final chunk. -inline std::string encodeLastChunk() { return "0\r\n\r\n"; } +inline std::string encodeLastChunk() { + return "0\r\n\r\n"; +} } // namespace http diff --git a/src/http/status.cpp b/src/http/status.cpp index dbddade..582be92 100644 --- a/src/http/status.cpp +++ b/src/http/status.cpp @@ -62,6 +62,8 @@ std::string_view reasonPhrase(Status status) { return "Unknown"; } -bool statusOmitsBody(int code) { return code < 200 || code == 204 || code == 304; } +bool statusOmitsBody(int code) { + return code < 200 || code == 204 || code == 304; +} } // namespace http diff --git a/src/main.cpp b/src/main.cpp index f0e7cf0..2f7dc35 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,7 @@ #include "app/signals.h" -#include "http/request.h" #include "net/context.h" #include "server/server.h" +#include "server/static_file.h" #include #include @@ -13,18 +13,18 @@ int main(int argc, char** argv) { port = std::atoi(argv[1]); } if (port < 1 || port > 65535) { - std::fprintf(stderr, "usage: http_server [port]\n"); + std::fprintf(stderr, "usage: http_server [port] [root]\n"); return 2; } try { net::Context ctx; app::ShutdownSignals signals; - server::Server server(static_cast(port), - [](const http::Request&) { - return std::string("Hello from http-server-cpp\n"); - }); - std::printf("listening on http://127.0.0.1:%d/ (stop with Ctrl-C)\n", port); + const std::string root = argc > 2 ? argv[2] : "."; + server::StaticFiles files(root); + server::Server server(static_cast(port), files); + std::printf("serving %s on http://127.0.0.1:%d/ (stop with Ctrl-C)\n", + files.root().string().c_str(), port); std::fflush(stdout); server.run(signals); std::puts("stopped"); diff --git a/src/net/context.h b/src/net/context.h index 28bb217..dcbfe4e 100644 --- a/src/net/context.h +++ b/src/net/context.h @@ -7,7 +7,7 @@ namespace net { // Process-wide socket subsystem session. One instance, created before any Socket, // destroyed after the last Socket dies. No-op on POSIX. class Context { - public: +public: Context(); ~Context(); Context(const Context&) = delete; @@ -16,7 +16,7 @@ class Context { bool ok() const { return ok_; } int version() const { return version_; } // Winsock level on Windows, 0 on POSIX - private: +private: #ifdef _WIN32 WSADATA wsaData_; #endif diff --git a/src/net/detail_raw.h b/src/net/detail_raw.h index 2588708..45a19fc 100644 --- a/src/net/detail_raw.h +++ b/src/net/detail_raw.h @@ -10,10 +10,10 @@ namespace detail { // OS primitives. Error detail stays in the OS error slot (WSAGetLastError/errno); // read it via net::lastError() immediately after a failed call. -handle_t rawSocket(); // stream socket, IPv4 (CloExec where supported) -int rawClose(handle_t); // 0 on success +handle_t rawSocket(); // stream socket, IPv4 (CloExec where supported) +int rawClose(handle_t); // 0 on success bool rawSetNonBlocking(handle_t, bool); -handle_t rawAccept(handle_t listener); // raw; then call rawAfterAccept +handle_t rawAccept(handle_t listener); // raw; then call rawAfterAccept // Make the accepted socket's blocking mode match the listener's, so the portable // contract holds on both OSes: Windows inherits automatically, Linux does not. void rawAfterAccept(handle_t listener, handle_t accepted); diff --git a/src/net/poll.h b/src/net/poll.h index 7ba7d6d..ed49dbe 100644 --- a/src/net/poll.h +++ b/src/net/poll.h @@ -18,17 +18,15 @@ inline constexpr short PollNval = POLLNVAL; // Thin poll()/WSAPoll fd set. Only the first pollSockets() return value of entries // carry meaningful revents (Windows leaves the rest untouched). class PollSet { - public: - void add(detail::handle_t socket, short events) { - fds_.push_back({socket, events, 0}); - } +public: + void add(detail::handle_t socket, short events) { fds_.push_back({socket, events, 0}); } void clear() { fds_.clear(); } std::size_t size() const { return fds_.size(); } detail::handle_t socket(std::size_t index) const { return fds_[index].fd; } short revents(std::size_t index) const { return fds_[index].revents; } detail::pollfd_t* data() { return fds_.data(); } - private: +private: std::vector fds_; }; diff --git a/src/net/raw_posix.cpp b/src/net/raw_posix.cpp index 91881b7..2842fcb 100644 --- a/src/net/raw_posix.cpp +++ b/src/net/raw_posix.cpp @@ -8,9 +8,13 @@ namespace net { namespace detail { -handle_t rawSocket() { return ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); } +handle_t rawSocket() { + return ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); +} -int rawClose(handle_t handle) { return ::close(handle); } +int rawClose(handle_t handle) { + return ::close(handle); +} bool rawSetNonBlocking(handle_t handle, bool enabled) { const int flags = ::fcntl(handle, F_GETFL, 0); diff --git a/src/net/raw_win.cpp b/src/net/raw_win.cpp index 1add51f..e04050b 100644 --- a/src/net/raw_win.cpp +++ b/src/net/raw_win.cpp @@ -7,16 +7,22 @@ namespace net { namespace detail { -handle_t rawSocket() { return ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); } +handle_t rawSocket() { + return ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); +} -int rawClose(handle_t handle) { return ::closesocket(handle); } +int rawClose(handle_t handle) { + return ::closesocket(handle); +} bool rawSetNonBlocking(handle_t handle, bool enabled) { u_long mode = enabled ? 1UL : 0UL; return ::ioctlsocket(handle, FIONBIO, &mode) != SOCKET_ERROR; } -handle_t rawAccept(handle_t listener) { return ::accept(listener, nullptr, nullptr); } +handle_t rawAccept(handle_t listener) { + return ::accept(listener, nullptr, nullptr); +} // Winsock: the accepted socket already inherited the listener's blocking mode. void rawAfterAccept(handle_t, handle_t) {} diff --git a/src/net/socket.h b/src/net/socket.h index 56cc062..acaab80 100644 --- a/src/net/socket.h +++ b/src/net/socket.h @@ -11,7 +11,7 @@ namespace net { // failure; the reason is in lastError() (only when the failure came from the OS // layer — calling on an invalid handle just returns false). class Socket { - public: +public: Socket() = default; explicit Socket(detail::handle_t handle) : handle_(handle) {} ~Socket() { close(); } @@ -52,7 +52,7 @@ class Socket { // >0 bytes read, 0 peer closed, -1 error (WouldBlock possible on non-blocking). int recvSome(void* buffer, std::size_t capacity); - private: +private: static detail::handle_t detach(Socket& s) { const detail::handle_t h = s.handle_; s.handle_ = detail::kInvalidHandle; diff --git a/src/net/socket_common.cpp b/src/net/socket_common.cpp index 098ce57..b3ca2cb 100644 --- a/src/net/socket_common.cpp +++ b/src/net/socket_common.cpp @@ -7,7 +7,9 @@ namespace net { -Socket Socket::tcp4() { return Socket(detail::rawSocket()); } +Socket Socket::tcp4() { + return Socket(detail::rawSocket()); +} bool Socket::setNonBlocking(bool enabled) { return valid() && detail::rawSetNonBlocking(handle_, enabled); @@ -67,8 +69,7 @@ bool Socket::connect(const sockaddr_in& address) { if (!valid()) { return false; } - return ::connect(handle_, reinterpret_cast(&address), sizeof(address)) != - -1; + return ::connect(handle_, reinterpret_cast(&address), sizeof(address)) != -1; } bool Socket::close() { @@ -107,8 +108,7 @@ int Socket::recvSome(void* buffer, std::size_t capacity) { if (!valid()) { return -1; } - return detail::rawRecv( - handle_, buffer, std::min(capacity, static_cast(INT_MAX))); + return detail::rawRecv(handle_, buffer, std::min(capacity, static_cast(INT_MAX))); } } // namespace net diff --git a/src/server/connection.cpp b/src/server/connection.cpp index 01f486b..c345728 100644 --- a/src/server/connection.cpp +++ b/src/server/connection.cpp @@ -1,35 +1,50 @@ #include "server/connection.h" #include "http/response.h" -#include "http/status.h" #include "net/error.h" namespace server { -Connection::Connection(net::Socket socket, const Handler& handler, - std::chrono::milliseconds idle) +Connection::Connection(net::Socket socket, const Handler& handler, std::chrono::milliseconds idle, + std::size_t max_queued) : sock_(std::move(socket)), handler_(&handler), - idle_ms_(static_cast(idle.count() < 0 ? 0 : idle.count())) { + idle_ms_(static_cast(idle.count() < 0 ? 0 : idle.count())), + max_queued_(max_queued == 0 ? 1 : max_queued) { deadline_ms_ = util::monotonicMs() + idle_ms_; } -void Connection::respond(http::Status status, std::string_view body, bool closeAfter) { - http::Response resp(status); - resp.setHeader("Content-Type", "text/plain; charset=utf-8"); +void Connection::respond(const Reply& reply, bool closeAfter, bool omitBody) { + http::Response resp(reply.status); + if (!reply.content_type.empty()) { + resp.setHeader("Content-Type", reply.content_type); + } if (closeAfter) { resp.setHeader("Connection", "close"); } - resp.setBody(std::string(body)); - out_ += resp.serialize(); - want_write_ = out_off_ < out_.size(); + if (omitBody) { + // HEAD: truthful Content-Length, body never written. + resp.setHeader("Content-Length", std::to_string(reply.body.size())); + } else { + resp.setBody(reply.body); + } + std::string head; + if (!resp.serializeHead(head)) { + close(); // header content we must not put on the wire + return; + } + out_ += head; + if (!omitBody) { + out_ += reply.body; + } + want_write_ = pending() > 0; close_after_write_ = close_after_write_ || closeAfter; } -bool Connection::drainParser() { +Connection::Drain Connection::drainParser() { for (;;) { const auto res = parser_.next(); if (res == http::RequestParser::Result::NeedMore) { - return true; + return Drain::NeedInput; } if (res == http::RequestParser::Result::Error) { using Err = http::RequestParser::Error; @@ -49,55 +64,97 @@ bool Connection::drainParser() { st = http::Status::BadRequest; break; } - // Protocol violation: answer once (best effort), then drop the - // connection — smuggling candidates never share a stream. - respond(st, std::string(http::reasonPhrase(st)) + "\n", true); + // Protocol violation: answer once (best effort), then drop — + // smuggling candidates never share a stream. + respond( + Reply{st, "text/plain; charset=utf-8", std::string(http::reasonPhrase(st)) + "\n"}, + true, false); flush(); close(); - return false; + return Drain::Done; } + const http::Request& req = parser_.request(); const bool ka = parser_.keepAlive(); - const std::string body = (*handler_)(parser_.request()); + const bool head = req.method == "HEAD"; + Reply reply; + try { + reply = (*handler_)(req); + } catch (...) { + // Exceptions never cross the reactor; one broken request must not + // take the process (or the connection's peer) down. + reply = Reply{http::Status::InternalError, "text/plain; charset=utf-8", + "500 Internal Server Error\n"}; + } parser_.reset(); - respond(http::Status::OK, body, !ka || shutting_down_); + // During shutdown a queued reply (Connection: close) must not strand + // pipelined requests that were already parsed: keep serving. + respond(reply, !ka || (shutting_down_ && pending() == 0), head); deadline_ms_ = util::monotonicMs() + idle_ms_; - if (close_after_write_) { - return false; + if (close_after_write_ || dead_) { + return Drain::Done; + } + if (pending() >= max_queued_) { + // Slow reader: keep remaining pipelined requests buffered in the + // parser (capped per request), stop reading, wait for POLLOUT. + return Drain::Throttled; } } } +Connection::Drain Connection::drainAndPump() { + for (;;) { + if (dead_ || close_after_write_) { + return Drain::Done; + } + const Drain d = drainParser(); + if (d != Drain::Throttled) { + return d; + } + flush(); // may empty the queue entirely — then keep draining + if (dead_ || pending() < max_queued_) { + continue; + } + return Drain::Throttled; // queue still full: POLLOUT resumes in onWritable + } +} + void Connection::onReadable() { if (dead_) { return; } - bool more = drainParser(); - while (more) { - if (close_after_write_ || dead_) { - more = false; - break; - } + Drain d = drainAndPump(); + while (d == Drain::NeedInput && !close_after_write_ && !dead_) { const int n = sock_.recvSome(rbuf_, sizeof(rbuf_)); if (n > 0) { parser_.feed(std::string_view(rbuf_, static_cast(n))); - more = drainParser(); + d = drainAndPump(); continue; } if (n < 0 && net::lastError().kind == net::ErrorKind::WouldBlock) { - more = false; break; } close(); // EOF or error return; } - if (shutting_down_ && !want_write_) { - close(); + if (dead_) { + return; + } + if (shutting_down_) { + // Drain what we have; a connection with a half-parsed request may + // legitimately stay idle until the 408 deadline — the reactor's grace + // cap bounds it. + if (!dead_ && close_after_write_ && pending() == 0) { + close(); + } return; } if (want_write_) { flush(); // optimistic: often fits the kernel without a POLLOUT round-trip } - if (!dead_ && close_after_write_ && out_off_ == out_.size()) { + if (!dead_ && !close_after_write_ && pending() < max_queued_) { + drainAndPump(); // POLLOUT may be spent this round — resume here too + } + if (!dead_ && close_after_write_ && pending() == 0) { close(); } } @@ -116,7 +173,7 @@ void Connection::flush() { close(); return; } - if (!dead_ && out_off_ == out_.size()) { + if (!dead_ && pending() == 0) { out_.clear(); out_off_ = 0; want_write_ = false; @@ -128,7 +185,13 @@ void Connection::onWritable() { return; } flush(); - if (!dead_ && close_after_write_ && out_off_ == out_.size()) { + if (dead_) { + return; + } + if (!close_after_write_ && pending() < max_queued_) { + drainAndPump(); // room again: resume a throttled drain (POLLOUT may vanish) + } + if (!dead_ && close_after_write_ && pending() == 0) { close(); } } @@ -137,20 +200,26 @@ void Connection::onTimeout() { if (dead_) { return; } - respond(http::Status::RequestTimeout, "408 Request Timeout\n", true); - flush(); - close(); + respond( + Reply{http::Status::RequestTimeout, "text/plain; charset=utf-8", "408 Request Timeout\n"}, + true, false); + if (!dead_) { + flush(); + close(); + } } void Connection::beginShutdown() { shutting_down_ = true; - if (!want_write_ && out_off_ == out_.size()) { + if (!want_write_ && pending() == 0) { close(); } else { close_after_write_ = true; } } -void Connection::close() { dead_ = true; } +void Connection::close() { + dead_ = true; +} } // namespace server diff --git a/src/server/connection.h b/src/server/connection.h index fff9bbf..36aaede 100644 --- a/src/server/connection.h +++ b/src/server/connection.h @@ -3,9 +3,11 @@ #include "http/request.h" #include "http/status.h" #include "net/socket.h" +#include "server/handler.h" #include "util/time.h" #include +#include #include #include #include @@ -13,15 +15,16 @@ namespace server { // One keep-alive HTTP connection driven by reactor callbacks. Reads requests -// through http::RequestParser, renders responses through the shared handler, -// buffers outbound bytes, and closes when told (idle timeout, parse error, +// through http::RequestParser, renders replies through the shared handler, +// buffers outbound bytes — capped, so a slow reader cannot make the server +// hoard responses — and closes when told (idle timeout, parse error, // Connection: close, or server shutdown). class Connection { - public: - // Renders the body for one request. Must not retain request views. - using Handler = std::function; +public: + using Handler = ::server::Handler; - Connection(net::Socket socket, const Handler& handler, std::chrono::milliseconds idle); + Connection(net::Socket socket, const Handler& handler, std::chrono::milliseconds idle, + std::size_t max_queued); Connection(const Connection&) = delete; Connection& operator=(const Connection&) = delete; @@ -38,16 +41,22 @@ class Connection { // Finish what is queued, then close; no further reads. void beginShutdown(); - private: - void respond(http::Status status, std::string_view body, bool closeAfter); - // Maps http::RequestParser::Error; returns false once the connection is dying. - bool drainParser(); +private: + enum class Drain { NeedInput, Throttled, Done }; + + Drain drainParser(); + // drainParser + immediate flush, re-draining when a throttle freed up; a + // throttle that could not flush leaves POLLOUT armed for onWritable. + Drain drainAndPump(); + void respond(const Reply& reply, bool closeAfter, bool omitBody); void flush(); void close(); + std::size_t pending() const { return out_.size() - out_off_; } net::Socket sock_; const Handler* handler_; std::uint64_t idle_ms_; + std::size_t max_queued_; http::RequestParser parser_; std::string out_; std::size_t out_off_ = 0; diff --git a/src/server/handler.h b/src/server/handler.h new file mode 100644 index 0000000..f38894e --- /dev/null +++ b/src/server/handler.h @@ -0,0 +1,21 @@ +#pragma once + +#include "http/request.h" +#include "http/status.h" + +#include +#include + +namespace server { + +// What a handler returns. HEAD is handled by the connection: the body is +// dropped but Content-Length stays honest. +struct Reply { + http::Status status = http::Status::OK; + std::string content_type = "text/plain; charset=utf-8"; + std::string body; +}; + +using Handler = std::function; + +} // namespace server diff --git a/src/server/server.cpp b/src/server/server.cpp index aa25362..2ec2675 100644 --- a/src/server/server.cpp +++ b/src/server/server.cpp @@ -10,8 +10,7 @@ namespace server { -Server::Server(std::uint16_t port, Connection::Handler handler) - : handler_(std::move(handler)) { +Server::Server(std::uint16_t port, Handler handler) : handler_(std::move(handler)) { listener_ = net::Socket::tcp4(); bool ok = listener_.valid(); if (ok) { @@ -28,8 +27,7 @@ Server::Server(std::uint16_t port, Connection::Handler handler) } if (!ok) { const auto e = net::lastError(); - throw std::runtime_error("listener setup failed (os error " + std::to_string(e.code) + - ")"); + throw std::runtime_error("listener setup failed (os error " + std::to_string(e.code) + ")"); } } @@ -70,16 +68,16 @@ void Server::run(ShutdownSignal& signal) { } const long long left = c->deadlineMs() >= now ? static_cast(c->deadlineMs() - now) : 0; - timeout = timeout < 0 ? static_cast(left) - : std::min(timeout, static_cast(left)); + timeout = + timeout < 0 ? static_cast(left) : std::min(timeout, static_cast(left)); } if (const int cap = static_cast(signal.pollCap().count()); cap > 0) { timeout = timeout < 0 ? cap : std::min(timeout, cap); } if (stopping) { const long long left = hard >= now ? static_cast(hard - now) : 0; - timeout = timeout < 0 ? static_cast(left) - : std::min(timeout, static_cast(left)); + timeout = + timeout < 0 ? static_cast(left) : std::min(timeout, static_cast(left)); } const int r = net::pollSockets(set, timeout); @@ -101,8 +99,8 @@ void Server::run(ShutdownSignal& signal) { if (!s.valid()) { break; // none pending (or accept error — retried next wakeup) } - conns_.push_back( - std::make_unique(std::move(s), handler_, idle_timeout)); + conns_.push_back(std::make_unique(std::move(s), handler_, idle_timeout, + max_queued_bytes)); } } @@ -137,9 +135,7 @@ void Server::run(ShutdownSignal& signal) { void Server::reap() { conns_.erase(std::remove_if(conns_.begin(), conns_.end(), - [](const std::unique_ptr& c) { - return c->dead(); - }), + [](const std::unique_ptr& c) { return c->dead(); }), conns_.end()); } diff --git a/src/server/server.h b/src/server/server.h index 856a41e..e8a92f6 100644 --- a/src/server/server.h +++ b/src/server/server.h @@ -2,6 +2,7 @@ #include "net/socket.h" #include "server/connection.h" +#include "server/handler.h" #include "server/shutdown.h" #include @@ -15,9 +16,9 @@ namespace server { // fixed handler. run() returns after the stop signal, once queued responses // drained or the grace expired. Threading (worker hand-off) comes later. class Server { - public: +public: // Throws std::runtime_error when bind/listen fails. - Server(std::uint16_t port, Connection::Handler handler); + Server(std::uint16_t port, Handler handler); Server(const Server&) = delete; Server& operator=(const Server&) = delete; @@ -25,15 +26,16 @@ class Server { void run(ShutdownSignal& signal); - std::chrono::milliseconds idle_timeout{5000}; // per-connection request timeout - std::chrono::milliseconds shutdown_grace{5000}; // drain budget after stop - int accept_batch = 64; // accepts per poll wakeup + std::chrono::milliseconds idle_timeout{5000}; // per-connection request timeout + std::chrono::milliseconds shutdown_grace{5000}; // drain budget after stop + int accept_batch = 64; // accepts per poll wakeup + std::size_t max_queued_bytes = 1u << 20; // per-connection response backlog - private: +private: void reap(); net::Socket listener_; - Connection::Handler handler_; + Handler handler_; std::vector> conns_; }; diff --git a/src/server/shutdown.h b/src/server/shutdown.h index 12e95a8..cf24bac 100644 --- a/src/server/shutdown.h +++ b/src/server/shutdown.h @@ -13,7 +13,7 @@ namespace server { // poll, so an implementation without a wake handle (Windows console events) // only needs a bounded pollCap(). class ShutdownSignal { - public: +public: virtual ~ShutdownSignal() = default; virtual bool stopRequested() const = 0; diff --git a/src/server/static_file.cpp b/src/server/static_file.cpp new file mode 100644 index 0000000..73d3072 --- /dev/null +++ b/src/server/static_file.cpp @@ -0,0 +1,226 @@ +#include "server/static_file.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +int hexVal(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; +} + +// Strict: '%' must introduce exactly two hex digits; a decoded NUL refuses. +bool percentDecode(std::string_view in, std::string& out) { + out.clear(); + out.reserve(in.size()); + for (std::size_t i = 0; i < in.size(); ++i) { + const char c = in[i]; + if (c != '%') { + out.push_back(c); + continue; + } + if (i + 2 >= in.size()) { + return false; + } + const int hi = hexVal(in[i + 1]); + const int lo = hexVal(in[i + 2]); + if (hi < 0 || lo < 0) { + return false; + } + const char v = static_cast(hi * 16 + lo); + if (v == '\0') { + return false; + } + out.push_back(v); + i += 2; + } + return true; +} + +std::string toLower(std::string_view s) { + std::string out(s); + for (char& c : out) { + c = static_cast(std::tolower(static_cast(c))); + } + return out; +} + +} // namespace + +namespace server { + +std::string_view mimeFor(std::string_view filename) { + const auto dot = filename.find_last_of('.'); + const std::string ext = + dot == std::string_view::npos ? std::string() : toLower(filename.substr(dot + 1)); + if (ext == "html" || ext == "htm") { + return "text/html; charset=utf-8"; + } + if (ext == "css") { + return "text/css; charset=utf-8"; + } + if (ext == "js") { + return "text/javascript; charset=utf-8"; + } + if (ext == "json" || ext == "map") { + return "application/json"; + } + if (ext == "txt") { + return "text/plain; charset=utf-8"; + } + if (ext == "png") { + return "image/png"; + } + if (ext == "jpg" || ext == "jpeg") { + return "image/jpeg"; + } + if (ext == "gif") { + return "image/gif"; + } + if (ext == "svg") { + return "image/svg+xml"; + } + if (ext == "ico") { + return "image/vnd.microsoft.icon"; + } + if (ext == "pdf") { + return "application/pdf"; + } + if (ext == "wasm") { + return "application/wasm"; + } + return "application/octet-stream"; +} + +StaticFiles::StaticFiles(std::filesystem::path root) { + std::error_code ec; + root_ = fs::canonical(root, ec); + if (ec || !fs::is_directory(root_)) { + throw fs::filesystem_error("static root is not a directory", root, ec); + } +} + +bool StaticFiles::contained(const fs::path& candidate) const { + std::error_code ec; + fs::path real = fs::weakly_canonical(candidate, ec); // resolves symlink prefixes + if (ec) { + real = candidate.lexically_normal(); + } + auto rel = real.lexically_relative(root_); + if (rel.empty() || *rel.begin() == "..") { + return false; + } +#ifdef _WIN32 + // Windows path equality is case-insensitive; compare folded, or a + // differently-cased root prefix would look like an escape. + auto folded = + fs::path(toLower(real.string())).lexically_relative(fs::path(toLower(root_.string()))); + if (folded.empty() || *folded.begin() == "..") { + return false; + } +#endif + return true; +} + +bool StaticFiles::resolve(std::string_view target, fs::path& out) const { + if (target.empty() || target.front() != '/') { + return false; + } + const auto q = target.find_first_of("?#"); + target = target.substr(0, q == std::string_view::npos ? target.size() : q); + + std::string decoded; + if (!percentDecode(target, decoded)) { + return false; + } + if (decoded.find('\\') != std::string::npos) { // Windows separator smuggling + return false; + } + for (const char c : decoded) { + if (static_cast(c) < 0x20) { + return false; + } + } + + const std::string rel = decoded.substr(1); // drop the leading '/' + try { + const fs::path relp(rel); + if (relp.is_absolute() || relp.has_root_path()) { + return false; // "/C:/Windows/..." must not replace the root on Windows + } + const fs::path candidate = (root_ / relp).lexically_normal(); + if (!contained(candidate)) { + return false; + } + out = candidate; + return true; + } catch (const fs::filesystem_error&) { + // Windows paths are wide: invalid UTF-8 (e.g. "%c0%af") throws on + // conversion — a hostile name, not a server error. + return false; + } +} + +Reply StaticFiles::operator()(const http::Request& req) const { + Reply r; + if (req.method != "GET" && req.method != "HEAD") { + // shortcut: no Allow header — Reply carries no per-reply headers, + // upgrade when a handler needs custom headers. + r.status = http::Status::MethodNotAllowed; + r.body = "405 Method Not Allowed\n"; + return r; + } + fs::path p; + std::error_code ec; + if (!resolve(req.target, p)) { + r.status = http::Status::NotFound; + r.body = "404 Not Found\n"; + return r; + } + if (fs::is_directory(p, ec)) { + p /= "index.html"; + if (!contained(p)) { + r.status = http::Status::NotFound; + r.body = "404 Not Found\n"; + return r; + } + } + if (!fs::is_regular_file(p, ec)) { + r.status = http::Status::NotFound; + r.body = "404 Not Found\n"; + return r; + } + std::ifstream f(p, std::ios::binary); + if (!f) { + r.status = http::Status::NotFound; // vanished between stat and open + r.body = "404 Not Found\n"; + return r; + } + // shortcut: whole-file read; bounded streaming lands with Range support. + std::string body; + { + std::ostringstream buf; + buf << f.rdbuf(); + body = buf.str(); + } + r.content_type = std::string(mimeFor(p.filename().string())); + r.body = std::move(body); + return r; +} + +} // namespace server diff --git a/src/server/static_file.h b/src/server/static_file.h new file mode 100644 index 0000000..2cddcbc --- /dev/null +++ b/src/server/static_file.h @@ -0,0 +1,37 @@ +#pragma once + +#include "server/handler.h" + +#include +#include + +namespace server { + +// Maps request targets to files under a canonical root. GET/HEAD only (405); +// traversal, encoded traversal, absolute targets and backslashes are rejected +// as 404 before touching the disk. Directories serve index.html; unknown +// extensions get application/octet-stream. Whole-file reads (streaming and +// conditional requests are the caching phase). +class StaticFiles { +public: + // Throws std::filesystem::filesystem_error when root does not exist. + explicit StaticFiles(std::filesystem::path root); + + Reply operator()(const http::Request& req) const; + + // Decoded, normalised, containment-checked absolute path; false = refuse. + // Public so tests hammer the resolver directly. + bool resolve(std::string_view target, std::filesystem::path& out) const; + + const std::filesystem::path& root() const { return root_; } + +private: + bool contained(const std::filesystem::path& candidate) const; + + std::filesystem::path root_; // canonical, exists +}; + +// Extension → media type (case-insensitive); octet-stream fallback. +std::string_view mimeFor(std::string_view filename); + +} // namespace server diff --git a/src/util/time.h b/src/util/time.h index a726baf..55f2c23 100644 --- a/src/util/time.h +++ b/src/util/time.h @@ -1,16 +1,15 @@ #pragma once -#include #include +#include namespace util { // Wall-clock-independent milliseconds for deadlines (immune to clock changes). inline std::uint64_t monotonicMs() { - return static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count()); + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); } } // namespace util diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 6199036..009d9e5 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -1,4 +1,6 @@ #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include "doctest.h" -TEST_CASE("toolchain produces a working test binary") { CHECK(1 + 1 == 2); } +TEST_CASE("toolchain produces a working test binary") { + CHECK(1 + 1 == 2); +} diff --git a/tests/test_request.cpp b/tests/test_request.cpp index 662040d..66c524d 100644 --- a/tests/test_request.cpp +++ b/tests/test_request.cpp @@ -39,9 +39,8 @@ TEST_CASE("valid minimal GET") { TEST_CASE("fields, headers, body") { RequestParser p; - REQUIRE(feedAndParse(p, - "POST /up HTTP/1.0\r\nHost: h\r\nContent-Length: 5\r\nX-A: 1\r\n" - "X-A: 2\r\n\r\nhello") == RequestParser::Result::Request); + REQUIRE(feedAndParse(p, "POST /up HTTP/1.0\r\nHost: h\r\nContent-Length: 5\r\nX-A: 1\r\n" + "X-A: 2\r\n\r\nhello") == RequestParser::Result::Request); const auto& r = p.request(); CHECK(r.method == "POST"); CHECK(r.version == "1.0"); @@ -80,9 +79,8 @@ TEST_CASE("never reads past declared body") { TEST_CASE("pipelined requests, one buffer") { RequestParser p; - REQUIRE(feedAndParse(p, - "GET /one HTTP/1.1\r\n\r\n" - "GET /two HTTP/1.1\r\n\r\n") == RequestParser::Result::Request); + REQUIRE(feedAndParse(p, "GET /one HTTP/1.1\r\n\r\n" + "GET /two HTTP/1.1\r\n\r\n") == RequestParser::Result::Request); CHECK(p.request().target == "/one"); p.reset(); REQUIRE(p.next() == RequestParser::Result::Request); @@ -96,8 +94,7 @@ TEST_CASE("byte-at-a-time feed completes") { TEST_CASE("empty lines before request line are skipped") { RequestParser p; - CHECK(feedAndParse(p, "\r\n\r\nGET / HTTP/1.1\r\n\r\n") == - RequestParser::Result::Request); + CHECK(feedAndParse(p, "\r\n\r\nGET / HTTP/1.1\r\n\r\n") == RequestParser::Result::Request); CHECK(p.request().target == "/"); } @@ -110,11 +107,9 @@ TEST_CASE("bad requests are rejected") { {"no spaces", "GET /\r\n\r\n", RequestParser::Error::BadRequest}, {"extra space", "GET / HTTP/1.1\r\n\r\n", RequestParser::Error::BadRequest}, {"bad version", "GET / HTTP/2.0\r\n\r\n", RequestParser::Error::VersionNotSupported}, - {"version suffix", "GET / HTTP/1.15\r\n\r\n", - RequestParser::Error::VersionNotSupported}, + {"version suffix", "GET / HTTP/1.15\r\n\r\n", RequestParser::Error::VersionNotSupported}, {"method not token", "G[T / HTTP/1.1\r\n\r\n", RequestParser::Error::BadRequest}, - {"header no colon", "GET / HTTP/1.1\r\nbroken\r\n\r\n", - RequestParser::Error::BadRequest}, + {"header no colon", "GET / HTTP/1.1\r\nbroken\r\n\r\n", RequestParser::Error::BadRequest}, {"header no name", "GET / HTTP/1.1\r\n: v\r\n\r\n", RequestParser::Error::BadRequest}, {"obs fold", "GET / HTTP/1.1\r\nA: 1\r\n b\r\n\r\n", RequestParser::Error::BadRequest}, {"bare LF in headers", "GET / HTTP/1.1\r\nA: 1\nB: 2\r\n\r\n", diff --git a/tests/test_server.cpp b/tests/test_server.cpp index 77b1ded..e663eb9 100644 --- a/tests/test_server.cpp +++ b/tests/test_server.cpp @@ -9,18 +9,25 @@ #include #include +#include #include #include namespace { class ManualSignal final : public server::ShutdownSignal { - public: +public: bool stopRequested() const override { return stop.load(); } std::chrono::milliseconds pollCap() const override { return std::chrono::milliseconds(20); } std::atomic_bool stop{false}; }; +server::Reply okReply(std::string body = "ok\n") { + server::Reply r; + r.body = std::move(body); + return r; +} + sockaddr_in loopback(unsigned short port) { sockaddr_in addr{}; addr.sin_family = AF_INET; @@ -32,8 +39,7 @@ sockaddr_in loopback(unsigned short port) { void setRecvTimeout(net::Socket& s, int ms) { #ifdef _WIN32 DWORD v = static_cast(ms); - ::setsockopt(s.handle(), SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&v), - sizeof(v)); + ::setsockopt(s.handle(), SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&v), sizeof(v)); #else timeval tv{ms / 1000, static_cast(ms % 1000) * 1000L}; ::setsockopt(s.handle(), SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); @@ -50,35 +56,46 @@ net::Socket connectTo(unsigned short port) { } // One full response: head + Content-Length bytes, or whatever arrived before -// timeout/EOF. -std::string readResponse(net::Socket& s) { - std::string buf; +// timeout/EOF. Leftover bytes of coalesced responses stay in `carry`. +std::string readResponse(net::Socket& s, std::string& carry) { + std::string buf = std::move(carry); + carry.clear(); char chunk[256]; for (;;) { + // Complete response already buffered (pipelined leftovers)? + const std::size_t head_end = buf.find("\r\n\r\n"); + if (head_end != std::string::npos) { + const std::size_t cl_pos = buf.find("Content-Length: "); + std::size_t total = std::string::npos; + if (cl_pos == std::string::npos || cl_pos > head_end) { + total = head_end + 4; // our server always sets Content-Length + } else { + std::size_t cl = 0; + std::size_t i = cl_pos + 16; + while (i < buf.size() && buf[i] >= '0' && buf[i] <= '9') { + cl = cl * 10 + static_cast(buf[i++] - '0'); + } + total = head_end + 4 + cl; + } + if (buf.size() >= total) { + carry = buf.substr(total); + buf.resize(total); + return buf; + } + } const int n = s.recvSome(chunk, sizeof(chunk)); if (n <= 0) { - return buf; + return buf; // timeout or EOF with an incomplete response } buf.append(chunk, static_cast(n)); - const std::size_t head_end = buf.find("\r\n\r\n"); - if (head_end == std::string::npos) { - continue; - } - const std::size_t cl_pos = buf.find("Content-Length: "); - if (cl_pos == std::string::npos || cl_pos > head_end) { - return buf; // our server always sets Content-Length; nothing to wait for - } - std::size_t cl = 0; - std::size_t i = cl_pos + 16; - while (i < buf.size() && buf[i] >= '0' && buf[i] <= '9') { - cl = cl * 10 + static_cast(buf[i++] - '0'); - } - if (buf.size() >= head_end + 4 + cl) { - return buf; - } } } +std::string readResponse(net::Socket& s) { + std::string ignored; + return readResponse(s, ignored); +} + bool seesEof(net::Socket& s) { char b[8]; return s.recvSome(b, sizeof(b)) == 0; // 0 = clean EOF; -1 = timeout/error @@ -94,7 +111,7 @@ TEST_CASE("200 over keep-alive, then Connection: close, then clean stop") { net::Context ctx; ManualSignal sig; server::Server srv(0, [](const http::Request& req) { - return req.target == "/close" ? std::string("bye\n") : std::string("ok\n"); + return okReply(req.target == "/close" ? "bye\n" : "ok\n"); }); const unsigned short port = srv.port(); REQUIRE(port > 0); @@ -122,7 +139,7 @@ TEST_CASE("200 over keep-alive, then Connection: close, then clean stop") { TEST_CASE("idle connection receives 408 then gets closed") { net::Context ctx; ManualSignal sig; - server::Server srv(0, [](const http::Request&) { return std::string("ok\n"); }); + server::Server srv(0, [](const http::Request&) { return okReply(); }); srv.idle_timeout = std::chrono::milliseconds(150); const unsigned short port = srv.port(); std::thread runner([&] { srv.run(sig); }); @@ -147,7 +164,7 @@ TEST_CASE("idle connection receives 408 then gets closed") { TEST_CASE("malformed request gets 400 and the connection drops") { net::Context ctx; ManualSignal sig; - server::Server srv(0, [](const http::Request&) { return std::string("ok\n"); }); + server::Server srv(0, [](const http::Request&) { return okReply(); }); const unsigned short port = srv.port(); std::thread runner([&] { srv.run(sig); }); @@ -165,7 +182,7 @@ TEST_CASE("malformed request gets 400 and the connection drops") { TEST_CASE("stop while connections are idle returns promptly") { net::Context ctx; ManualSignal sig; - server::Server srv(0, [](const http::Request&) { return std::string("ok\n"); }); + server::Server srv(0, [](const http::Request&) { return okReply(); }); const unsigned short port = srv.port(); std::thread runner([&] { srv.run(sig); }); @@ -179,3 +196,76 @@ TEST_CASE("stop while connections are idle returns promptly") { runner.join(); // idle connection is closed by beginShutdown; no grace wait CHECK(seesEof(c)); } + +TEST_CASE("slow-loris partial request head times out with 408") { + net::Context ctx; + ManualSignal sig; + server::Server srv(0, [](const http::Request&) { return okReply(); }); + srv.idle_timeout = std::chrono::milliseconds(150); + const unsigned short port = srv.port(); + std::thread runner([&] { srv.run(sig); }); + + auto c = connectTo(port); + REQUIRE(c.valid()); + REQUIRE(sendReq(c, "GET / HTT")); // drip that never finishes + std::this_thread::sleep_for(std::chrono::milliseconds(400)); + const std::string r = readResponse(c); + CHECK(r.find("408") != std::string::npos); + CHECK(seesEof(c)); + + sig.stop.store(true); + runner.join(); +} + +TEST_CASE("pipelined burst survives a tiny per-connection write queue") { + net::Context ctx; + ManualSignal sig; + server::Server srv(0, [](const http::Request&) { return okReply(); }); + srv.max_queued_bytes = 100; // one response exceeds it: throttle/resume every time + const unsigned short port = srv.port(); + std::thread runner([&] { srv.run(sig); }); + + auto c = connectTo(port); + REQUIRE(c.valid()); + std::string burst; + for (int i = 0; i < 10; ++i) { + burst += "GET /p HTTP/1.1\r\n\r\n"; + } + REQUIRE(sendReq(c, burst)); + std::string carry; // responses coalesce into few reads; keep the seam honest + for (int i = 0; i < 10; ++i) { + const std::string r = readResponse(c, carry); + CHECK(r.find("HTTP/1.1 200 OK") != std::string::npos); + CHECK(r.find("\r\n\r\nok\n") != std::string::npos); + } + CHECK(!seesEof(c)); // keep-alive still open after the storm + + sig.stop.store(true); + runner.join(); +} + +TEST_CASE("HEAD answers with truthful Content-Length and no body") { + net::Context ctx; + ManualSignal sig; + server::Server srv(0, [](const http::Request&) { return okReply("hello"); }); + const unsigned short port = srv.port(); + std::thread runner([&] { srv.run(sig); }); + + auto c = connectTo(port); + REQUIRE(c.valid()); + REQUIRE(sendReq(c, "HEAD /x HTTP/1.1\r\n\r\n")); + const std::string head = readResponse(c); // body never comes; returns on timeout + const std::size_t head_end = head.find("\r\n\r\n"); + REQUIRE(head_end != std::string::npos); + CHECK(head.find("Content-Length: 5") != std::string::npos); + CHECK(head.size() == head_end + 4); // exactly the head: zero body bytes + + // stream stayed framed: a GET right after still lines up + REQUIRE(sendReq(c, "GET /x HTTP/1.1\r\n\r\n")); + const std::string r = readResponse(c); + CHECK(r.find("HTTP/1.1 200 OK") != std::string::npos); + CHECK(r.find("\r\n\r\nhello") != std::string::npos); + + sig.stop.store(true); + runner.join(); +} diff --git a/tests/test_static.cpp b/tests/test_static.cpp new file mode 100644 index 0000000..3f3d0d6 --- /dev/null +++ b/tests/test_static.cpp @@ -0,0 +1,138 @@ +#include "doctest.h" + +#include "http/request.h" +#include "server/static_file.h" + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +void writeFile(const fs::path& p, std::string_view content) { + fs::create_directories(p.parent_path()); + std::ofstream f(p, std::ios::binary); + f.write(content.data(), static_cast(content.size())); +} + +struct TestTree { + fs::path root; + fs::path outside; + TestTree() { + root = fs::temp_directory_path() / ("hs_static_" + std::to_string(::rand())); + outside = root.parent_path() / ("hs_outside_" + std::to_string(::rand())); + fs::create_directories(root); + writeFile(root / "index.html", "root"); + writeFile(root / "a.txt", "alpha"); + writeFile(root / "nested" / "index.html", "nested-index"); + writeFile(root / "noext", "raw"); + writeFile(root / "UPPER.HTML", "upper"); + writeFile(outside / "secret.txt", "SECRET"); + } + ~TestTree() { + std::error_code ec; + fs::remove_all(root, ec); + fs::remove_all(outside, ec); + } +}; + +http::Request makeRequest(std::string_view method, std::string_view target) { + http::Request req; + req.method = method; + req.target = target; + req.version = "1.1"; + return req; +} + +} // namespace + +TEST_CASE("static files: serving and content types") { + TestTree tree; + server::StaticFiles files(tree.root); + + auto r = files(makeRequest("GET", "/a.txt")); + CHECK(r.status == http::Status::OK); + CHECK(r.body == "alpha"); + CHECK(r.content_type == "text/plain; charset=utf-8"); + + r = files(makeRequest("GET", "/")); + CHECK(r.body == "root"); + CHECK(r.content_type == "text/html; charset=utf-8"); + + r = files(makeRequest("GET", "/nested/")); + CHECK(r.body == "nested-index"); + + r = files(makeRequest("GET", "/noext")); + CHECK(r.body == "raw"); + CHECK(r.content_type == "application/octet-stream"); + + r = files(makeRequest("GET", "/UPPER.HTML")); + CHECK(r.status == http::Status::OK); // exists as written + + r = files(makeRequest("GET", "/a.txt?query=1#frag")); + CHECK(r.body == "alpha"); // query and fragment stripped + + r = files(makeRequest("GET", "/a%2etxt")); + CHECK(r.body == "alpha"); // percent-decoded before resolution +} + +TEST_CASE("static files: traversal and hostile targets refuse with 404") { + TestTree tree; + server::StaticFiles files(tree.root); + + const char* hostile[] = { + "/../secret.txt", + "/%2e%2e/secret.txt", + "/%2E%2E%2Fsecret.txt", + "/nested/../../secret.txt", + "/%2e%2e/%2e%2e/secret.txt", + "/\\..\\secret.txt", // backslash smuggling + "/C:/Windows/win.ini", // absolute on Windows, odd name on posix + "/%c0%afx", // overlong encoding: just a weird name + "/%zz", // invalid escape + "/a%00b", // decoded NUL + "/nested/../nested/../secret.txt", + }; + for (const char* target : hostile) { + const auto r = files(makeRequest("GET", target)); + CHECK_MESSAGE(r.status == http::Status::NotFound, + target << " -> " << static_cast(r.status)); + CHECK_MESSAGE(r.body.find("SECRET") == std::string::npos, target); + } + + fs::path out; + CHECK_FALSE(files.resolve("", out)); + CHECK_FALSE(files.resolve("relative/path", out)); // must start with '/' + CHECK(files.resolve("/a.txt", out)); + // root_ is canonical; on these platforms temp is too, but don't assume: + CHECK(out == fs::canonical(tree.root) / "a.txt"); +} + +TEST_CASE("static files: methods and misses") { + TestTree tree; + server::StaticFiles files(tree.root); + + auto r = files(makeRequest("POST", "/a.txt")); + CHECK(r.status == http::Status::MethodNotAllowed); + r = files(makeRequest("DELETE", "/a.txt")); + CHECK(r.status == http::Status::MethodNotAllowed); + r = files(makeRequest("HEAD", "/missing")); + CHECK(r.status == http::Status::NotFound); + + // HEAD on an existing file keeps the honest length (connection drops body) + r = files(makeRequest("HEAD", "/a.txt")); + CHECK(r.status == http::Status::OK); + CHECK(r.body == "alpha"); +} + +TEST_CASE("mimeFor table") { + CHECK(server::mimeFor("page.html") == "text/html; charset=utf-8"); + CHECK(server::mimeFor("PAGE.HTM") == "text/html; charset=utf-8"); + CHECK(server::mimeFor("app.JS") == "text/javascript; charset=utf-8"); + CHECK(server::mimeFor("photo.jpeg") == "image/jpeg"); + CHECK(server::mimeFor("style.css") == "text/css; charset=utf-8"); + CHECK(server::mimeFor("archive.tar") == "application/octet-stream"); + CHECK(server::mimeFor("noext") == "application/octet-stream"); +} diff --git a/tools/asan-inside.sh b/tools/asan-inside.sh index 0fee41e..e4b7701 100644 --- a/tools/asan-inside.sh +++ b/tools/asan-inside.sh @@ -18,11 +18,15 @@ export TSAN_OPTIONS=halt_on_error=1 # Graceful-shutdown smoke: SIGINT must drain, exit 0 (leak check rides on exit). echo "== SIGINT graceful stop smoke ==" -/tmp/asan/http_server 18099 & +root=$(mktemp -d) +printf 'ok' > "$root/index.html" +/tmp/asan/http_server 18099 "$root" & srv=$! sleep 0.5 line=$(printf 'GET / HTTP/1.1\r\n\r\n' | timeout 5 nc 127.0.0.1 18099 | head -1) echo "$line" | grep -q "200" || { echo "smoke: expected 200, got: $line"; kill -9 $srv; exit 1; } +tline=$(printf 'GET /../../etc/shadow HTTP/1.1\r\n\r\n' | timeout 5 nc 127.0.0.1 18099 | head -1) +echo "$tline" | grep -q "404" || { echo "smoke: expected traversal 404, got: $tline"; kill -9 $srv; exit 1; } kill -INT $srv for _ in $(seq 40); do kill -0 $srv 2>/dev/null || break; sleep 0.1; done if kill -0 $srv 2>/dev/null; then