Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
19 changes: 15 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
185 changes: 185 additions & 0 deletions src/http/date.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#include "http/date.h"

#include <array>
#include <cctype>
#include <cstdio>
#include <vector>

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<unsigned char>(t[0])) &&
std::isalpha(static_cast<unsigned char>(t[1])) &&
std::isalpha(static_cast<unsigned char>(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<unsigned char>(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<unsigned>(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<int>(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<unsigned>(z - era * 146097);
const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
const long long yy = static_cast<long long>(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<char>(std::tolower(static_cast<unsigned char>(t[j])));
const char b =
static_cast<char>(std::tolower(static_cast<unsigned char>(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<long long>(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<std::time_t> parseHttpDate(std::string_view value) {
// Split on separators; "dd-Mon-yy" folds into the same token stream.
std::vector<std::string_view> 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<unsigned char>(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<unsigned>(y), static_cast<unsigned>(mo),
static_cast<unsigned>(d))) {
return std::nullopt;
}
const long long days = daysFromCivil(y, static_cast<unsigned>(mo), static_cast<unsigned>(d));
return static_cast<std::time_t>(days * 86400 + h * 3600 + mi * 60 + s);
}

} // namespace http
19 changes: 19 additions & 0 deletions src/http/date.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#pragma once

#include <ctime>
#include <optional>
#include <string>
#include <string_view>

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<std::time_t> parseHttpDate(std::string_view value);

} // namespace http
24 changes: 16 additions & 8 deletions src/server/connection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion src/server/handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@

#include <functional>
#include <string>
#include <utility>
#include <vector>

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 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<std::pair<std::string, std::string>> headers;
};

using Handler = std::function<Reply(const http::Request&)>;
Expand Down
Loading
Loading