From bff34031b3bd4d75247818e5b706687020f45c98 Mon Sep 17 00:00:00 2001 From: AgentDudu Date: Tue, 20 Oct 2026 20:42:38 +0700 Subject: [PATCH] feat: conditional requests and single-range serving for static files StaticFiles now emits strong ETag (size+mtime) and Last-Modified; If-None-Match (weak comparison, star, lists) and If-Modified-Since return bodyless 304 before the file is read, with If-None-Match taking precedence per RFC 7232. Single byte-ranges serve 206 + Content-Range, past-the-end 416 + bytes */len, If-Range gates with strong comparison; multi-range, other units and malformed ranges are ignored per RFC 9110. Reply gained a per-response header list (405 now carries Allow). New http/date.{h,cpp}: IMF/RFC850/asctime parse + IMF format via civil-calendar math, no timegm/_mkgmtime portability traps. 44 cases / 473 assertions incl. date garbage table and 14 range cases. --- CMakeLists.txt | 2 + PLAN.md | 4 +- docs/ARCHITECTURE.md | 19 +++- src/http/date.cpp | 185 ++++++++++++++++++++++++++++++ src/http/date.h | 19 ++++ src/server/connection.cpp | 24 ++-- src/server/handler.h | 7 +- src/server/static_file.cpp | 223 ++++++++++++++++++++++++++++++++++++- src/server/static_file.h | 6 +- tests/test_date.cpp | 50 +++++++++ tests/test_static.cpp | 168 ++++++++++++++++++++++++++++ 11 files changed, 687 insertions(+), 20 deletions(-) create mode 100644 src/http/date.cpp create mode 100644 src/http/date.h create mode 100644 tests/test_date.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 674867f..328f1bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ set(HTTP_WARNINGS -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Werror) add_library(http STATIC src/http/status.cpp src/http/headers.cpp + src/http/date.cpp src/http/request.cpp src/http/response.cpp) target_include_directories(http PUBLIC src) @@ -94,6 +95,7 @@ add_executable(unit_tests tests/test_socket.cpp tests/test_request.cpp tests/test_response.cpp + tests/test_date.cpp tests/test_server.cpp tests/test_static.cpp) target_include_directories(unit_tests SYSTEM PRIVATE external/doctest) diff --git a/PLAN.md b/PLAN.md index 629b37c..20ce560 100644 --- a/PLAN.md +++ b/PLAN.md @@ -176,12 +176,12 @@ phase/step/milestone wording, ever. | 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 | | ✓ | ~~`feature/reactor-threading`~~ | acceptor thread + K reactors + socket hand-off inbox (`net::Wakeup`) | merged — cross-reactor keep-alive/pipelining/stop-drain tests; TSan green; EINTR double-join fixed | | ✓ | ~~`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 | +| G | `feature/caching-and-range` | ETag, Last-Modified, If-None-Match, Range/206/416 | merged — 304 (weak/list/star/IMS-precedence) + single-range 206/416 + If-Range tests; multi-range ignored (200) by design; ASan + TSan green | | 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) merged on top — T (threading) can land next. +All of C, R, T, S, G merged; H (hardening soak/corpus) then D (docs/benchmark) remain. ## 8. Git workflow (gitflow) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b380e7f..faaff40 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,7 +17,9 @@ until then. send/recv, reported to the reactor from poll as `ErrorKind::Interrupted`. - `glibc` needs `_GNU_SOURCE` with `-std=c++20` for `accept4`/`SOCK_CLOEXEC`. - glibc `setsockopt` requires `int` option size; Winsock is lenient. -- `http/` — status, headers, request parser, response serialization landed. Parser is +- `http/` — status, headers, request parser, response serialization, HTTP-date + (IMF/RFC 850/asctime parse + IMF format, no libc `timegm`/`_mkgmtime` — + portable civil-calendar math) 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. URL decode/normalise and MIME live @@ -33,12 +35,21 @@ until then. joiner). A slow reader throttles its connection — queued responses stop growing, remaining pipelined requests wait in the parser, POLLOUT or the next read resumes the drain. - Handlers return `Reply{status, content_type, body}`; HEAD keeps an honest - `Content-Length` and no body; handler exceptions become 500 at the reactor seam. + Handlers return `Reply{status, content_type, body, headers}`; HEAD keeps an honest + `Content-Length` and no body (and no `Content-Length` at all on 304); 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. + Windows), index.html for directories, 405 elsewhere (with `Allow`), whole-file reads. + Caching/revalidation: strong `ETag` (size + mtime) and `Last-Modified` on every + 200; `If-None-Match` (weak comparison, `*`, lists) and `If-Modified-Since` + (If-None-Match alone decides when present) short-circuit to bodyless 304 before + the file is read; single `Range` → 206 + `Content-Range`, past-the-end → 416 + + `bytes */len`, `If-Range` (strong validator comparison) gates the range; anything + unparsable, multi-range, or non-`bytes` unit is ignored and the whole entity is + served. A truncate race between stat and read degrades the 206 to 416 rather + than a short body. 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. diff --git a/src/http/date.cpp b/src/http/date.cpp new file mode 100644 index 0000000..3a86d64 --- /dev/null +++ b/src/http/date.cpp @@ -0,0 +1,185 @@ +#include "http/date.h" + +#include +#include +#include +#include + +namespace http { + +namespace { + +constexpr const char* kDays[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; +constexpr const char* kMonths[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + +bool isAlpha3(std::string_view t) { + return t.size() == 3 && std::isalpha(static_cast(t[0])) && + std::isalpha(static_cast(t[1])) && + std::isalpha(static_cast(t[2])); +} + +bool allDigits(std::string_view t) { + if (t.empty() || t.size() > 4) { + return false; + } + for (char c : t) { + if (!std::isdigit(static_cast(c))) { + return false; + } + } + return true; +} + +long toLong(std::string_view t) { + long v = 0; + for (char c : t) { + v = v * 10 + (c - '0'); + } + return v; +} + +// Days since 1970-01-01 (Howard Hinnant's civil calendar algorithm). +long long daysFromCivil(long long y, unsigned m, unsigned d) { + y -= m <= 2; + const long long era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + static_cast(doe) - 719468; +} + +void civilFromDays(long long z0, long long& y, unsigned& m, unsigned& d) { + const long long z = z0 + 719468; + const long long era = (z >= 0 ? z : z - 146096) / 146097; + const unsigned doe = static_cast(z - era * 146097); + const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + const long long yy = static_cast(yoe) + era * 400; + const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + const unsigned mp = (5 * doy + 2) / 153; + d = doy - (153 * mp + 2) / 5 + 1; + m = mp + (mp < 10 ? 3 : -9); + y = yy + (m <= 2 ? 1 : 0); +} + +bool daysInMonth(unsigned y, unsigned m, unsigned d) { + static const unsigned kLen[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (m < 1 || m > 12 || d < 1) { + return false; + } + unsigned len = kLen[m - 1]; + if (m == 2 && ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0)) { + len = 29; + } + return d <= len; +} + +int monthFromName(std::string_view t) { + for (int i = 0; i < 12; ++i) { + bool same = true; + for (int j = 0; j < 3; ++j) { + const char a = static_cast(std::tolower(static_cast(t[j]))); + const char b = + static_cast(std::tolower(static_cast(kMonths[i][j]))); + if (a != b) { + same = false; + break; + } + } + if (same) { + return i + 1; + } + } + return -1; +} + +bool parseHms(std::string_view t, long& h, long& m, long& s) { + if (t.size() != 8 || t[2] != ':' || t[5] != ':') { + return false; + } + const auto part = [&](std::string_view p, long& out) { + if (!allDigits(p) || p.size() != 2) { + return false; + } + out = toLong(p); + return true; + }; + return part(t.substr(0, 2), h) && part(t.substr(3, 2), m) && part(t.substr(6, 2), s); +} + +} // namespace + +std::string formatHttpDate(std::time_t epochSeconds) { + const long long secs = static_cast(epochSeconds); + long long days = secs / 86400; + long long rem = secs % 86400; + if (rem < 0) { + rem += 86400; + --days; + } + long long y; + unsigned mo, d; + civilFromDays(days, y, mo, d); + char buf[64]; // fits worst-case wide-year expansion gcc likes to warn about + std::snprintf(buf, sizeof(buf), "%s, %02u %s %04lld %02lld:%02lld:%02lld GMT", + kDays[(((days + 4) % 7) + 7) % 7], d, kMonths[mo - 1], y, rem / 3600, + (rem / 60) % 60, rem % 60); + return buf; +} + +std::optional parseHttpDate(std::string_view value) { + // Split on separators; "dd-Mon-yy" folds into the same token stream. + std::vector tok; + { + std::string_view rest = value; + while (!rest.empty()) { + const auto cut = rest.find_first_of(" ,-\t"); + const auto piece = cut == std::string_view::npos ? rest : rest.substr(0, cut); + if (!piece.empty()) { // asctime's padded day doubles up separators + tok.push_back(piece); + } + rest = cut == std::string_view::npos ? std::string_view{} : rest.substr(cut + 1); + } + } + + long y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0; + std::string_view timeTok; + if (tok.size() == 5 && isAlpha3(tok[0]) && isAlpha3(tok[1]) && allDigits(tok[2])) { + // asctime: "Sun Nov 6 08:49:37 1994" (day space-padded -> its separator + // produced an empty token, already dropped, so tok[1] is the month). + mo = monthFromName(tok[1]); + if (mo < 0 || !allDigits(tok[4])) { + return std::nullopt; + } + d = toLong(tok[2]); + y = toLong(tok[4]); + timeTok = tok[3]; + } else if (tok.size() == 6 && allDigits(tok[1]) && isAlpha3(tok[2]) && allDigits(tok[3])) { + // "06 Nov 1994 ..." and "06 Nov 94 ..." (RFC 850 with '-' separators). + if (tok[5].size() != 3 || std::tolower(static_cast(tok[5][0])) != 'g') { + return std::nullopt; // timezone (GMT/UTC/EST...) must be present + } + d = toLong(tok[1]); + mo = monthFromName(tok[2]); + if (mo < 0) { + return std::nullopt; + } + y = toLong(tok[3]); + if (tok[3].size() == 2) { + y += y < 70 ? 2000 : 1900; + } + timeTok = tok[4]; + } else { + return std::nullopt; + } + + if (!parseHms(timeTok, h, mi, s) || mi > 59 || s > 60 || h > 23 || + !daysInMonth(static_cast(y), static_cast(mo), + static_cast(d))) { + return std::nullopt; + } + const long long days = daysFromCivil(y, static_cast(mo), static_cast(d)); + return static_cast(days * 86400 + h * 3600 + mi * 60 + s); +} + +} // namespace http diff --git a/src/http/date.h b/src/http/date.h new file mode 100644 index 0000000..dd25053 --- /dev/null +++ b/src/http/date.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +namespace http { + +// RFC 7231 IMF-fixdate: "Sun, 06 Nov 1994 08:49:37 GMT". +std::string formatHttpDate(std::time_t epochSeconds); + +// Accepts the three dates HTTP recipients MUST parse: IMF-fixdate, +// RFC 850 ("Sunday, 06-Nov-94 08:49:37 GMT", two-digit year pivoted at 1970) +// and asctime ("Sun Nov 6 08:49:37 1994"). nullopt for anything else, +// including missing timezone or out-of-range fields. +std::optional parseHttpDate(std::string_view value); + +} // namespace http diff --git a/src/server/connection.cpp b/src/server/connection.cpp index c345728..3895766 100644 --- a/src/server/connection.cpp +++ b/src/server/connection.cpp @@ -15,16 +15,19 @@ Connection::Connection(net::Socket socket, const Handler& handler, std::chrono:: void Connection::respond(const Reply& reply, bool closeAfter, bool omitBody) { http::Response resp(reply.status); + for (const auto& entry : reply.headers) { + resp.setHeader(entry.first, entry.second); + } if (!reply.content_type.empty()) { resp.setHeader("Content-Type", reply.content_type); } if (closeAfter) { resp.setHeader("Connection", "close"); } - if (omitBody) { + if (omitBody && !http::statusOmitsBody(static_cast(reply.status))) { // HEAD: truthful Content-Length, body never written. resp.setHeader("Content-Length", std::to_string(reply.body.size())); - } else { + } else if (!omitBody) { resp.setBody(reply.body); } std::string head; @@ -66,9 +69,11 @@ Connection::Drain Connection::drainParser() { } // 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); + respond(Reply{st, + "text/plain; charset=utf-8", + std::string(http::reasonPhrase(st)) + "\n", + {}}, + true, false); flush(); close(); return Drain::Done; @@ -82,8 +87,10 @@ Connection::Drain Connection::drainParser() { } 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"}; + reply = Reply{http::Status::InternalError, + "text/plain; charset=utf-8", + "500 Internal Server Error\n", + {}}; } parser_.reset(); // During shutdown a queued reply (Connection: close) must not strand @@ -201,7 +208,8 @@ void Connection::onTimeout() { return; } respond( - Reply{http::Status::RequestTimeout, "text/plain; charset=utf-8", "408 Request Timeout\n"}, + Reply{ + http::Status::RequestTimeout, "text/plain; charset=utf-8", "408 Request Timeout\n", {}}, true, false); if (!dead_) { flush(); diff --git a/src/server/handler.h b/src/server/handler.h index f38894e..b90f092 100644 --- a/src/server/handler.h +++ b/src/server/handler.h @@ -5,6 +5,8 @@ #include #include +#include +#include namespace server { @@ -12,8 +14,11 @@ namespace server { // 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 content_type = "text/plain; charset=utf-8"; // empty = omit header std::string body; + // Extra response headers (ETag, Content-Range, ...) emitted before + // Content-Type; values must already be header-field safe. + std::vector> headers; }; using Handler = std::function; diff --git a/src/server/static_file.cpp b/src/server/static_file.cpp index 73d3072..0114898 100644 --- a/src/server/static_file.cpp +++ b/src/server/static_file.cpp @@ -1,7 +1,14 @@ #include "server/static_file.h" +#include "http/date.h" + +#include #include +#include #include +#include +#include +#include #include #include #include @@ -60,6 +67,132 @@ std::string toLower(std::string_view s) { return out; } +std::string trim(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 std::string(s); +} + +// Wall-clock seconds for last_write_time, bridged with paired now()s — +// std::clock_cast is not in every supported stdlib.shortcut: microsecond drift +// on a header with one-second resolution. +std::time_t mtimeSeconds(const fs::path& p, std::error_code& ec) { + const auto lwt = fs::last_write_time(p, ec); + if (ec) { + return 0; + } + const auto wall = std::chrono::system_clock::now() + (lwt - std::chrono::file_clock::now()); + return std::chrono::duration_cast(wall.time_since_epoch()).count(); +} + +std::string makeEtag(std::uintmax_t size, std::time_t mtime) { + char buf[48]; + std::snprintf(buf, sizeof(buf), "\"%llx-%llx\"", static_cast(size), + static_cast(static_cast(mtime))); + return buf; +} + +// RFC 7232 3.2: If-None-Match uses weak comparison — "W/" prefixes ignored. +bool etagListMatches(std::string_view inm, std::string_view etag) { + if (trim(inm) == "*") { + return true; + } + for (std::size_t pos = 0; pos < inm.size();) { + const auto comma = inm.find(',', pos); + std::string_view item = + inm.substr(pos, comma == std::string_view::npos ? std::string_view::npos : comma - pos); + if (item.size() > 2 && item[0] == 'W' && item[1] == '/') { + item = item.substr(2); + } + if (trim(item) == etag) { + return true; + } + if (comma == std::string_view::npos) { + break; + } + pos = comma + 1; + } + return false; +} + +// RFC 7233 3.2: If-Range matches strongly — a W/ validator never matches. +bool ifRangeMatches(std::string_view ir, std::string_view etag, std::time_t lm) { + const std::string v = trim(ir); + if (!v.empty() && v.front() == '"') { + return v == etag; + } + const auto t = http::parseHttpDate(v); + return t && *t == lm; +} + +struct ByteRange { + enum Kind { Ignore, Satisfied, Unsatisfiable } kind = Ignore; + std::uintmax_t begin = 0; + std::uintmax_t end = 0; // inclusive +}; + +// Single byte-range only (RFC 9110 14.1.1 semantics); multi-ranges, syntax +// errors and unknown units mean "ignore the Range header" (serve 200). +ByteRange parseRange(std::string_view value, std::uintmax_t size) { + ByteRange r; + constexpr std::string_view unit = "bytes="; + if (value.substr(0, unit.size()) != unit || value.size() == unit.size()) { + return r; + } + std::string_view spec = value.substr(unit.size()); + if (spec.find(',') != std::string_view::npos) { + return r; // multiple ranges: not served, 200 whole entity instead + } + const auto dash = spec.find('-'); + if (dash == std::string_view::npos || dash + 1 > spec.size()) { + return r; + } + const auto digits = [](std::string_view t) { + if (t.empty() || t.size() > 20) { + return std::make_pair(false, std::uintmax_t{0}); + } + std::uintmax_t v = 0; + for (char c : t) { + if (c < '0' || c > '9') { + return std::make_pair(false, std::uintmax_t{0}); + } + v = v * 10 + static_cast(c - '0'); + } + return std::make_pair(true, v); + }; + const auto [bOk, begin] = digits(spec.substr(0, dash)); + const bool hasEnd = dash + 1 < spec.size(); + const auto [eOk, end] = + hasEnd ? digits(spec.substr(dash + 1)) : std::make_pair(true, std::uintmax_t{0}); + if (!bOk && !eOk) { + return r; // "-" alone + } + if (!bOk) { // suffix form "-N" + if (dash != 0 || !hasEnd || end == 0) { + return r; + } + if (size == 0) { + r.kind = ByteRange::Unsatisfiable; + return r; + } + r.kind = ByteRange::Satisfied; + r.begin = end >= size ? 0 : size - end; + r.end = size - 1; + return r; + } + if (!eOk || (hasEnd && end < begin)) { + return r; + } + r.kind = begin < size ? ByteRange::Satisfied : ByteRange::Unsatisfiable; + r.begin = begin; + r.end = hasEnd ? (size == 0 ? 0 : (end >= size ? size - 1 : end)) : (size == 0 ? 0 : size - 1); + return r; +} + } // namespace namespace server { @@ -179,10 +312,9 @@ bool StaticFiles::resolve(std::string_view target, fs::path& out) const { 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"; + r.headers = {{"Allow", "GET, HEAD"}}; return r; } fs::path p; @@ -205,19 +337,104 @@ Reply StaticFiles::operator()(const http::Request& req) const { r.body = "404 Not Found\n"; return r; } + const std::uintmax_t size = fs::file_size(p, ec); + if (ec) { + r.status = http::Status::NotFound; // vanished between stat and size + r.body = "404 Not Found\n"; + return r; + } + std::error_code tec; + const std::time_t lm = mtimeSeconds(p, tec); + const std::string etag = makeEtag(size, lm); + const std::string lmText = http::formatHttpDate(lm); + r.headers = {{"ETag", etag}, {"Last-Modified", lmText}, {"Accept-Ranges", "bytes"}}; + + // Conditional GET (RFC 7232): If-None-Match alone decides when present; + // If-Modified-Since only applies without it. + bool not_modified = false; + if (const std::string* inm = req.headers.get("If-None-Match")) { + not_modified = etagListMatches(*inm, etag); + } else if (const std::string* ims = req.headers.get("If-Modified-Since")) { + const auto t = http::parseHttpDate(*ims); + not_modified = t && !tec && lm <= *t; + } + if (not_modified) { + r.status = http::Status::NotModified; + r.content_type.clear(); // 304 carries no representation headers + return r; + } + + // Range applies to GET only; If-Range mismatch or anything unparsable + // means "serve the whole entity" (RFC 7233). + std::size_t from = 0; + std::size_t len = static_cast(size); + bool partial = false; + std::string contentRange; + if (req.method == "GET") { + if (const std::string* rangeHdr = req.headers.get("Range")) { + bool apply = true; + if (const std::string* ir = req.headers.get("If-Range")) { + apply = ifRangeMatches(*ir, etag, lm); + } + const ByteRange br = apply ? parseRange(*rangeHdr, size) : ByteRange{}; + if (br.kind == ByteRange::Unsatisfiable) { + r.status = http::Status::RangeNotSatisfiable; + r.body = "416 Range Not Satisfiable\n"; + r.headers = { + {"Content-Range", + "bytes */" + std::to_string(static_cast(size))}, + {"ETag", etag}, + {"Last-Modified", lmText}, + }; + return r; + } + if (br.kind == ByteRange::Satisfied) { + partial = true; + from = static_cast(br.begin); + len = static_cast(br.end - br.begin + 1); + contentRange = "bytes " + + std::to_string(static_cast(br.begin)) + "-" + + std::to_string(static_cast(br.end)) + "/" + + std::to_string(static_cast(size)); + } + } + } + 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"; + r.headers.clear(); return r; } - // shortcut: whole-file read; bounded streaming lands with Range support. + // shortcut: whole-file read then slice; bounded streaming is a later phase. std::string body; { std::ostringstream buf; buf << f.rdbuf(); body = buf.str(); } + if (from > body.size()) { + from = body.size(); // raced with a truncate between stat and read + } + len = std::min(len, body.size() - from); + if (partial) { + if (body.size() < from + len) { + // file shrank after stat: the promised range no longer exists. + r.status = http::Status::RangeNotSatisfiable; + r.body = "416 Range Not Satisfiable\n"; + r.headers = { + {"Content-Range", "bytes */" + std::to_string(body.size())}, + {"ETag", makeEtag(body.size(), lm)}, + {"Last-Modified", lmText}, + }; + return r; + } + r.status = http::Status::PartialContent; + r.headers.emplace_back("Content-Range", contentRange); + } + body.erase(0, from); + body.resize(len); r.content_type = std::string(mimeFor(p.filename().string())); r.body = std::move(body); return r; diff --git a/src/server/static_file.h b/src/server/static_file.h index 2cddcbc..4fbc59b 100644 --- a/src/server/static_file.h +++ b/src/server/static_file.h @@ -10,8 +10,10 @@ 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). +// extensions get application/octet-stream. Serves ETag + Last-Modified with +// If-None-Match/If-Modified-Since (304) and single byte-ranges (206/416, +// If-Range honoured; multi-range and bad ranges ignore the header). Whole-file +// reads; bounded streaming is a later phase. class StaticFiles { public: // Throws std::filesystem::filesystem_error when root does not exist. diff --git a/tests/test_date.cpp b/tests/test_date.cpp new file mode 100644 index 0000000..7e0e885 --- /dev/null +++ b/tests/test_date.cpp @@ -0,0 +1,50 @@ +#include "doctest.h" + +#include "http/date.h" + +#include + +TEST_CASE("http dates: format is IMF-fixdate") { + CHECK(http::formatHttpDate(784111777) == "Sun, 06 Nov 1994 08:49:37 GMT"); + CHECK(http::formatHttpDate(0) == "Thu, 01 Jan 1970 00:00:00 GMT"); + CHECK(http::formatHttpDate(-1) == "Wed, 31 Dec 1969 23:59:59 GMT"); + CHECK(http::formatHttpDate(2147483648LL) == "Tue, 19 Jan 2038 03:14:08 GMT"); +} + +TEST_CASE("http dates: the three legacy formats agree") { + const auto a = http::parseHttpDate("Sun, 06 Nov 1994 08:49:37 GMT"); + const auto b = http::parseHttpDate("Sunday, 06-Nov-94 08:49:37 GMT"); + const auto c = http::parseHttpDate("Sun Nov 6 08:49:37 1994"); + REQUIRE(a.has_value()); + REQUIRE(b.has_value()); + REQUIRE(c.has_value()); + CHECK(*a == 784111777); + CHECK(*b == 784111777); + CHECK(*c == 784111777); + + CHECK(http::parseHttpDate(http::formatHttpDate(1700000000)) == 1700000000); + CHECK(http::parseHttpDate("Thursday, 01-Jan-70 00:00:00 GMT") == + 0); // two-digit pivot: 70 -> 1970 + CHECK(http::parseHttpDate("Saturday, 01-Jan-99 00:00:00 GMT") == + http::parseHttpDate("Sat, 01 Jan 1999 00:00:00 GMT")); + CHECK(http::parseHttpDate("Fri, 29 Feb 2000 00:00:00 GMT").has_value()); // leap +} + +TEST_CASE("http dates: garbage is nullopt, never a guess") { + const char* bad[] = { + "", + "garbage", + "Sun, 06 Nov 1994 08:49:37", // no timezone + "Sun, 06 Foo 1994 08:49:37 GMT", // unknown month + "Sun, 31 Feb 1999 08:49:37 GMT", // not a leap year + "Sun, 00 Nov 1994 08:49:37 GMT", // day zero + "Sun, 06 Nov 1994 25:49:37 GMT", // hour + "Sun, 06 Nov 1994 08:60:37 GMT", // minute + "Sun, 06 Nov 1994 08:49:37 UTC+2", // offset form is not HTTP + "1994-11-06T08:49:37Z", // ISO is not HTTP + "Sun, 06-11-94 08:49:37 GMT", // numeric month in RFC850 slot + }; + for (const char* s : bad) { + CHECK_MESSAGE(!http::parseHttpDate(s).has_value(), s); + } +} diff --git a/tests/test_static.cpp b/tests/test_static.cpp index 3f3d0d6..c57c3ec 100644 --- a/tests/test_static.cpp +++ b/tests/test_static.cpp @@ -1,8 +1,10 @@ #include "doctest.h" +#include "http/date.h" #include "http/request.h" #include "server/static_file.h" +#include #include #include #include @@ -26,6 +28,7 @@ struct TestTree { fs::create_directories(root); writeFile(root / "index.html", "root"); writeFile(root / "a.txt", "alpha"); + writeFile(root / "ten.txt", "0123456789"); writeFile(root / "nested" / "index.html", "nested-index"); writeFile(root / "noext", "raw"); writeFile(root / "UPPER.HTML", "upper"); @@ -136,3 +139,168 @@ TEST_CASE("mimeFor table") { CHECK(server::mimeFor("archive.tar") == "application/octet-stream"); CHECK(server::mimeFor("noext") == "application/octet-stream"); } + +// ---- conditional requests and ranges (G) ------------------------------------ + +namespace { + +const std::string* replyHeader(const server::Reply& r, std::string_view name) { + for (const auto& h : r.headers) { + if (h.first == name) { + return &h.second; + } + } + return nullptr; +} + +http::Request condRequest(std::string_view target, std::string_view name, std::string value, + std::string_view method = "GET") { + auto req = makeRequest(method, target); + if (!name.empty()) { + req.headers.set(std::string(name), std::move(value)); + } + return req; +} + +std::string getEtag(server::StaticFiles& files, const http::Request& req) { + const auto r = files(req); + const std::string* etag = replyHeader(r, "ETag"); + REQUIRE(etag != nullptr); + return *etag; +} + +} // namespace + +TEST_CASE("conditional GET: validators present, 304 on match") { + TestTree tree; + server::StaticFiles files(tree.root); + + const auto base = files(makeRequest("GET", "/ten.txt")); + REQUIRE(base.status == http::Status::OK); + const std::string etag = getEtag(files, makeRequest("GET", "/ten.txt")); + CHECK(etag.front() == '"'); + REQUIRE(replyHeader(base, "Last-Modified") != nullptr); + CHECK(http::parseHttpDate(*replyHeader(base, "Last-Modified")).has_value()); + REQUIRE(replyHeader(base, "Accept-Ranges") != nullptr); + CHECK(*replyHeader(base, "Accept-Ranges") == "bytes"); + + const auto hit = files(condRequest("/ten.txt", "If-None-Match", etag)); + CHECK(hit.status == http::Status::NotModified); + CHECK(hit.body.empty()); + CHECK(hit.content_type.empty()); // 304 carries no representation + CHECK(replyHeader(hit, "ETag") != nullptr); + + // weak comparison (RFC 7232 3.2) and star and lists + CHECK(files(condRequest("/ten.txt", "If-None-Match", "W/" + etag)).status == + http::Status::NotModified); + CHECK(files(condRequest("/ten.txt", "If-None-Match", "*")).status == http::Status::NotModified); + CHECK(files(condRequest("/ten.txt", "If-None-Match", "\"other\", " + etag)).status == + http::Status::NotModified); + CHECK(files(condRequest("/ten.txt", "If-None-Match", "\"stale-etag\"")).status == + http::Status::OK); + CHECK(files(condRequest("/ten.txt", "If-None-Match", etag, "HEAD")).status == + http::Status::NotModified); + // missing file never takes a 304 + CHECK(files(condRequest("/missing", "If-None-Match", "*")).status == http::Status::NotFound); +} + +TEST_CASE("conditional GET: If-Modified-Since, If-None-Match wins") { + TestTree tree; + server::StaticFiles files(tree.root); + + const std::string future = http::formatHttpDate(std::time(nullptr) + 3600); + CHECK(files(condRequest("/ten.txt", "If-Modified-Since", future)).status == + http::Status::NotModified); + CHECK(files(condRequest("/ten.txt", "If-Modified-Since", "Sun, 06 Nov 1994 08:49:37 GMT")) + .status == http::Status::OK); + CHECK(files(condRequest("/ten.txt", "If-Modified-Since", "not-a-date")).status == + http::Status::OK); + + // RFC 7232 3.3: with If-None-Match present it alone decides — a future + // If-Modified-Since must not rescue a mismatched If-None-Match into 304. + auto both = makeRequest("GET", "/ten.txt"); + both.headers.set("If-None-Match", "\"nope\""); + both.headers.set("If-Modified-Since", future); + CHECK(files(both).status == http::Status::OK); +} + +TEST_CASE("ranges: single-range 206, 416, and ignore-what-we-do-not-parse") { + TestTree tree; + server::StaticFiles files(tree.root); + const std::string etag = getEtag(files, makeRequest("GET", "/ten.txt")); + + struct Case { + const char* range; + http::Status status; + std::string body; + const char* content_range; + }; + const Case cases[] = { + {"bytes=0-4", http::Status::PartialContent, "01234", "bytes 0-4/10"}, + {"bytes=5-", http::Status::PartialContent, "56789", "bytes 5-9/10"}, + {"bytes=-3", http::Status::PartialContent, "789", "bytes 7-9/10"}, + {"bytes=9-99", http::Status::PartialContent, "9", "bytes 9-9/10"}, + {"bytes=0-0", http::Status::PartialContent, "0", "bytes 0-0/10"}, + // unsatisfiable: first-pos past the end + {"bytes=10-", http::Status::RangeNotSatisfiable, "", "bytes */10"}, + {"bytes=100-200", http::Status::RangeNotSatisfiable, "", "bytes */10"}, + // unparsable or unsupported -> plain 200 with the whole entity + {"bytes=0-1,3-4", http::Status::OK, "0123456789", nullptr}, // multi-range + {"bytes=abc", http::Status::OK, "0123456789", nullptr}, + {"bytes=5-3", http::Status::OK, "0123456789", nullptr}, // reversed + {"bytes=-", http::Status::OK, "0123456789", nullptr}, // suffix-less + {"bytes=-0", http::Status::OK, "0123456789", nullptr}, // zero suffix + {"items=0-1", http::Status::OK, "0123456789", nullptr}, // other unit + {"bytes 0-1", http::Status::OK, "0123456789", nullptr}, // bad syntax + }; + for (const auto& c : cases) { + auto req = condRequest("/ten.txt", "Range", c.range); + const auto r = files(req); + INFO(c.range); + CHECK(r.status == c.status); + if (c.content_range != nullptr) { + REQUIRE(replyHeader(r, "Content-Range") != nullptr); + CHECK(*replyHeader(r, "Content-Range") == c.content_range); + } + if (c.status == http::Status::PartialContent) { + CHECK(r.body == c.body); + CHECK(replyHeader(r, "ETag") != nullptr); + } else if (c.status == http::Status::OK) { + CHECK(r.body == c.body); + } + } + + // HEAD ignores Range (it is not a range request per RFC 9110 14.2) + const auto head = files(condRequest("/ten.txt", "Range", "bytes=0-3", "HEAD")); + CHECK(head.status == http::Status::OK); + + // If-Range: matching validator applies the range, anything else serves all + auto ir_ok = condRequest("/ten.txt", "Range", "bytes=0-4"); + ir_ok.headers.set("If-Range", etag); + CHECK(files(ir_ok).status == http::Status::PartialContent); + + auto ir_stale = condRequest("/ten.txt", "Range", "bytes=0-4"); + ir_stale.headers.set("If-Range", "\"changed\""); + const auto all = files(ir_stale); + CHECK(all.status == http::Status::OK); + CHECK(all.body == "0123456789"); + + auto ir_date = condRequest("/ten.txt", "Range", "bytes=0-4"); + ir_date.headers.set("If-Range", + *replyHeader(files(makeRequest("GET", "/ten.txt")), "Last-Modified")); + CHECK(files(ir_date).status == http::Status::PartialContent); + + // weak ETag never matches If-Range (strong comparison) + auto ir_weak = condRequest("/ten.txt", "Range", "bytes=0-4"); + ir_weak.headers.set("If-Range", "W/" + etag); + CHECK(files(ir_weak).status == http::Status::OK); +} + +TEST_CASE("405 carries Allow") { + TestTree tree; + server::StaticFiles files(tree.root); + const auto r = files(makeRequest("POST", "/ten.txt")); + CHECK(r.status == http::Status::MethodNotAllowed); + REQUIRE(replyHeader(r, "Allow") != nullptr); + CHECK(*replyHeader(r, "Allow") == "GET, HEAD"); +}