diff --git a/.gitignore b/.gitignore index 0c6bd73..a95836f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,5 @@ webserv # superpowers working artifacts (specs + plans live locally only) docs/superpowers/ -# unit test binary -tests/unit/run_tests +# unit test binaries (one per suite -- request, response, ...) +tests/unit/run_*_tests diff --git a/Makefile b/Makefile index 4bd637a..7eac734 100644 --- a/Makefile +++ b/Makefile @@ -53,24 +53,35 @@ clean: fclean: clean @echo "$(RED)๐Ÿ—‘๏ธ Removing executable...$(RESET)" @rm -f $(TARGET) - @rm -f $(UNIT_BIN) + @rm -f $(UNIT_BINS) @echo "$(GREEN)โœ… Full clean complete!$(RESET)" # Rebuild re: fclean all -# Unit tests (phase 2.1+). Two-file build on purpose: request.cpp has no -# deps, so tests need no sockets, no server, no main.o. Same flags as the -# real build -- evaluators compile everything they find in the repo. -UNIT_SRC = tests/unit/test_request.cpp src/http/request.cpp -UNIT_BIN = tests/unit/run_tests +# Unit tests (phase 2.1+). One small two-file build per unit on purpose: +# request.cpp and response.cpp each have no deps, so a test needs no sockets, +# no server, no main.o. Same flags as the real build -- evaluators compile +# everything they find in the repo. Add a unit -> add its pair here. +REQ_SRC = tests/unit/test_request.cpp src/http/request.cpp +REQ_BIN = tests/unit/run_request_tests -unit: $(UNIT_BIN) - @./$(UNIT_BIN) +RESP_SRC = tests/unit/test_response.cpp src/http/response.cpp +RESP_BIN = tests/unit/run_response_tests -$(UNIT_BIN): $(UNIT_SRC) $(INCLUDE_DIR)/http.hpp - @echo "$(CYAN)๐Ÿงช Building unit tests...$(RESET)" - @$(CXX) $(CXXFLAGS) $(INCLUDES) -o $(UNIT_BIN) $(UNIT_SRC) +UNIT_BINS = $(REQ_BIN) $(RESP_BIN) + +unit: $(UNIT_BINS) + @./$(REQ_BIN) + @./$(RESP_BIN) + +$(REQ_BIN): $(REQ_SRC) $(INCLUDE_DIR)/http.hpp $(INCLUDE_DIR)/string_utils.hpp + @echo "$(CYAN)๐Ÿงช Building request unit tests...$(RESET)" + @$(CXX) $(CXXFLAGS) $(INCLUDES) -o $(REQ_BIN) $(REQ_SRC) + +$(RESP_BIN): $(RESP_SRC) $(INCLUDE_DIR)/http.hpp $(INCLUDE_DIR)/string_utils.hpp + @echo "$(CYAN)๐Ÿงช Building response unit tests...$(RESET)" + @$(CXX) $(CXXFLAGS) $(INCLUDES) -o $(RESP_BIN) $(RESP_SRC) # Show help help: diff --git a/includes/http.hpp b/includes/http.hpp index 2262ed0..9c6c140 100644 --- a/includes/http.hpp +++ b/includes/http.hpp @@ -3,6 +3,7 @@ #include #include #include +#include // Request/Response live here per the phase 0.2 layout -- Response joins in // task 2.2. @@ -87,3 +88,68 @@ class Request { void parseHeaderLine(const std::string& line); void setError(int code); }; + +// Response is the mirror of Request: where Request decodes a byte stream +// into fields, Response builds fields into a byte stream. serialize() is the +// only thing the server loop (2.5) ever needs -- it hands the bytes to the +// write buffer. +// +// I keep it I/O-free for the same reason Request is: no sockets, no Logger. +// A handler fills in the status/headers/body and serialize() turns it into +// "HTTP/1.1 ...\r\n...\r\n\r\nbody". That also makes the unit test a +// dependency-free two-file build, like the request tests. +// +// A fresh Response is already a valid empty 200 OK -- so a GET handler can +// just setBody() and serialize() without ceremony. Content-Length and Date +// are filled in at serialize() time so they can't drift out of sync with the +// body; the caller never has to remember them. + +class Response { + public: + Response(); + + // Status line pieces. The one-arg form looks the reason phrase up + // (setStatusCode(404) -> "Not Found") because every error site in + // 2.3/4.5 would otherwise hand-type the same strings. The two-arg + // form is the escape hatch for a code we don't name, or a CGI + // script that dictates its own reason. + void setStatusCode(int code); + void setStatusCode(int code, const std::string& reason); + + // Header names are case-insensitive (RFC 7230 3.2), so I key them + // lowercased like Request does and re-canonicalize on output. That + // means setHeader("content-type", ...) and a later + // setHeader("Content-Type", ...) are the SAME header -- the second + // overwrites, no accidental duplicate. + void setHeader(const std::string& name, const std::string& value); + + void setBody(const std::string& body); + + // Always the real body length -- serialize() trusts this over + // anything the caller may have set, so a wrong Content-Length can't + // desync the framing (a classic smuggling bug). + std::size_t getContentLength() const; + + // The whole message as one string. const because building bytes + // shouldn't mutate the object; the auto Date/Content-Length are + // merged into a local copy of the headers, not stored back. + std::string serialize() const; + + // RFC 1123 date ("Sun, 06 Nov 1994 08:49:37 GMT"). Static and takes + // the time explicitly so it's testable without freezing the clock -- + // serialize() feeds it time(NULL). Built by hand instead of + // strftime("%a"/"%b") because those are locale-dependent and HTTP + // dates MUST be the C locale's English abbreviations. + static std::string httpDate(std::time_t t); + + // Canonical reason phrase for the codes this server actually emits, + // "" for anything we don't name. Kept as a table so the status line + // and 4.5's error pages read from one source of truth. + static std::string reasonPhrase(int code); + + private: + int status_; + std::string reason_; + std::string body_; + std::map headers_; // keys lowercased +}; diff --git a/includes/string_utils.hpp b/includes/string_utils.hpp index 67ae84c..af662de 100644 --- a/includes/string_utils.hpp +++ b/includes/string_utils.hpp @@ -10,4 +10,24 @@ std::string toString(const T& value) { std::ostringstream oss; oss << value; return oss.str(); +} + +// ASCII-only lowercase. std::tolower is locale-dependent AND undefined +// behaviour on negative chars -- any byte >= 0x80 when char is signed, which +// network input absolutely contains. HTTP is ASCII, so I roll it by hand and +// skip entirely. Both the request parser and the response builder +// need case-insensitive header keys, so it lives here instead of being copied +// into each. inline because this header lands in several translation units -- +// a non-inline definition would break the ODR at link. +inline char asciiLower(char c) { + if (c >= 'A' && c <= 'Z') + return static_cast(c + 32); + return c; +} + +inline std::string lowerCopy(const std::string& s) { + std::string out(s); + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = asciiLower(out[i]); + return out; } \ No newline at end of file diff --git a/includes/utils.hpp b/includes/utils.hpp index 7b9637e..97c1119 100644 --- a/includes/utils.hpp +++ b/includes/utils.hpp @@ -1 +1,21 @@ -#pragma once \ No newline at end of file +#pragma once + +#include +#include +#include + +// Slurp a whole file into `out`. Returns false if the path can't be opened, +// true otherwise. Deliberately error-policy-free: the config loader turns a +// false into a ConfigException, the token-dump tool prints and bails, and the +// 2.3 static handler will turn it into a 404/403 -- each caller owns its own +// failure story, so this stays a plain bool and never throws or logs. inline +// because the header lands in several translation units. +inline bool readFileToString(const std::string& path, std::string& out) { + std::ifstream in(path.c_str()); + if (!in) + return false; + std::stringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} diff --git a/kanban.md b/kanban.md index c9db93e..2fc820a 100644 --- a/kanban.md +++ b/kanban.md @@ -241,7 +241,7 @@ --- -### [ ] 2.2: HTTP Response Construction +### [ completed ] 2.2: HTTP Response Construction **Assignee:** Dev B **Description:** - Create Response class in includes/http.hpp and src/http/response.cpp @@ -259,11 +259,11 @@ - Auto-set Content-Length and Date headers **DoD:** -- [ ] Constructs valid HTTP response with status, headers, body -- [ ] Response line correct: "HTTP/1.1 200 OK\r\n" -- [ ] Headers properly formatted with \r\n -- [ ] Body appended after blank line -- [ ] serialize() produces valid HTTP response +- [x] Constructs valid HTTP response with status, headers, body +- [x] Response line correct: "HTTP/1.1 200 OK\r\n" +- [x] Headers properly formatted with \r\n +- [x] Body appended after blank line +- [x] serialize() produces valid HTTP response --- diff --git a/src/config/config.cpp b/src/config/config.cpp index 0297e73..0834976 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -1,6 +1,5 @@ #include "../../includes/webserv.hpp" #include "../../includes/parser.hpp" -#include #include // the ctors for the config structs, and nothing else (this was the first @@ -59,12 +58,10 @@ const std::vector& Config::servers() const { // ConfigException formatted ":: ...". static std::string readFile(const std::string& path) { - std::ifstream in(path.c_str()); - if (!in) + std::string source; + if (!readFileToString(path, source)) throw ConfigException("cannot open config file '" + path + "'"); - std::stringstream ss; - ss << in.rdbuf(); - return ss.str(); + return source; } void Config::load(const std::string& path) { diff --git a/src/config/parser.cpp b/src/config/parser.cpp index e36c149..1e0d193 100644 --- a/src/config/parser.cpp +++ b/src/config/parser.cpp @@ -3,20 +3,17 @@ #include #include // std::cerr โ€” for the unknown-directive warnings #include // std::numeric_limits โ€” the overflow check in parseSize -#include // std::ifstream โ€” reading an included file // path helpers for the `include` directive. static so they stay local to this -// file. there's near-identical code in config.cpp โ€” I could pull both into a -// shared util in Phase 4 polish, but right now copying a few lines is cheaper -// than the header churn that would cause. +// file. the actual file slurp is shared with config.cpp now โ€” readFileToString +// in utils.hpp โ€” so all that's left here is wrapping its bool failure in the +// "included file" flavour of ConfigException. static std::string readIncludedFile(const std::string& path) { - std::ifstream in(path.c_str()); - if (!in) + std::string source; + if (!readFileToString(path, source)) throw ConfigException("cannot open included file '" + path + "'"); - std::stringstream ss; - ss << in.rdbuf(); - return ss.str(); + return source; } // grab the directory part of `path` โ€” everything up to and including the last diff --git a/src/http/request.cpp b/src/http/request.cpp index f1dcab1..13aaf92 100644 --- a/src/http/request.cpp +++ b/src/http/request.cpp @@ -1,4 +1,5 @@ #include "../../includes/http.hpp" +#include "../../includes/string_utils.hpp" // asciiLower / lowerCopy // Incremental HTTP request parser -- the state machine picture and the // no-exceptions rationale live in http.hpp. This file grows with the @@ -17,23 +18,6 @@ static const std::size_t MAX_REQUEST_LINE = 8 * 1024; static const std::size_t MAX_HEADER_LINE = 8 * 1024; static const std::size_t MAX_HEADER_COUNT = 100; -// Helper: std::tolower is locale-dependent AND undefined behaviour on -// negative chars -- any byte >= 0x80 when char is signed, which network -// input absolutely contains. HTTP is ASCII, so roll it by hand and skip -// entirely. -static char asciiLower(char c) { - if (c >= 'A' && c <= 'Z') - return static_cast(c + 32); - return c; -} - -static std::string lowerCopy(const std::string& s) { - std::string out(s); - for (std::size_t i = 0; i < out.size(); ++i) - out[i] = asciiLower(out[i]); - return out; -} - // OWS = "optional whitespace" in the RFC 7230 grammar: SP and HTAB only. // Not isspace() -- that would also eat \v\f\r, which the grammar doesn't. static std::string trimOws(const std::string& s) { diff --git a/src/http/response.cpp b/src/http/response.cpp new file mode 100644 index 0000000..b037dd7 --- /dev/null +++ b/src/http/response.cpp @@ -0,0 +1,202 @@ +#include "../../includes/http.hpp" +#include "../../includes/string_utils.hpp" +#include +#include + +// HTTP response builder -- the mirror of request.cpp. The why-it's-I/O-free +// and why-a-fresh-one-is-200 reasoning lives in http.hpp. This file just +// turns fields into the wire format. + +// asciiLower / lowerCopy now live in string_utils.hpp -- request.cpp wanted +// the exact same case-insensitive keys, so the two copies became one. + +// Turn the lowercased storage key back into the on-the-wire spelling: +// uppercase the first letter and every letter after a '-'. Gives +// "content-type" -> "Content-Type", "date" -> "Date". Casing is cosmetic +// (clients compare case-insensitively), but emitting the canonical form is +// what a real server does and keeps the output legible at a 42 defense. +static std::string canonicalHeaderName(const std::string& lower) { + std::string out(lower); + bool atStart = true; + for (std::size_t i = 0; i < out.size(); ++i) { + if (atStart && out[i] >= 'a' && out[i] <= 'z') + out[i] = static_cast(out[i] - 32); + atStart = (out[i] == '-'); + } + return out; +} + +// The Request parser is paranoid about CTLs in header names/values because +// they become CGI env vars in 3.3; the builder owes the wire the same paranoia +// in reverse. A CR or LF that slips into a header -- or the status-line reason +// -- is HTTP response splitting: a CGI script (3.3) or error page could inject +// its own headers or a forged body. So I refuse to EMIT what the parser refuses +// to ACCEPT, and at the same boundary. + +// Header name grammar (RFC 7230 3.2.6 token, kept strict): non-empty, no CTLs, +// no SP/HTAB, no ':' (that's the field separator). Anything else is a name +// that would break framing the moment it's written out. +static bool validHeaderName(const std::string& name) { + if (name.empty()) + return false; + for (std::size_t i = 0; i < name.size(); ++i) { + unsigned char uc = static_cast(name[i]); + if (uc < 0x20 || uc == 0x7F) return false; + if (name[i] == ' ' || name[i] == '\t' || name[i] == ':') return false; + } + return true; +} + +// Header value: no CTLs except HTAB, which RFC 7230 field-content allows inside +// a value (same rule the request parser uses). CR and LF are CTLs, so this is +// what actually stops the splitting. +static bool validHeaderValue(const std::string& value) { + for (std::size_t i = 0; i < value.size(); ++i) { + unsigned char uc = static_cast(value[i]); + if ((uc < 0x20 && uc != '\t') || uc == 0x7F) return false; + } + return true; +} + +// The reason phrase lands in "HTTP/1.1 \r\n", so any CTL there +// (CR/LF above all) splits the status line. No HTAB exception -- a reason +// phrase never needs one. +static bool reasonHasCtl(const std::string& reason) { + for (std::size_t i = 0; i < reason.size(); ++i) { + unsigned char uc = static_cast(reason[i]); + if (uc < 0x20 || uc == 0x7F) return true; + } + return false; +} + +Response::Response() + : status_(200) + , reason_("OK") + , body_() + , headers_() +{} + +void Response::setStatusCode(int code) { + status_ = code; + reason_ = reasonPhrase(code); // "" for a code we don't name; that's fine +} + +void Response::setStatusCode(int code, const std::string& reason) { + status_ = code; + // A CTL-bearing reason is an injection attempt (or a buggy CGI line) -- + // drop it back to the table phrase rather than echo attacker bytes into + // the status line. Falls through to "" for a code we don't name, which is + // still a safe (if bare) status line. + reason_ = reasonHasCtl(reason) ? reasonPhrase(code) : reason; +} + +void Response::setHeader(const std::string& name, const std::string& value) { + // Refuse to store a header that can't be safely written out. setHeader is + // void, so the only sane failure is to drop it whole -- a half-cleaned + // header is worse than none. The caller's contract is "give me a real + // header"; nothing in this server legitimately needs a CTL in one. + if (!validHeaderName(name) || !validHeaderValue(value)) + return; + headers_[lowerCopy(name)] = value; // lowercased key == case-insensitive +} + +void Response::setBody(const std::string& body) { + body_ = body; +} + +std::size_t Response::getContentLength() const { + return body_.size(); +} + +std::string Response::serialize() const { + // Work on a copy so serialize() stays const and the auto headers never + // stick to the object -- call it twice a second apart and only the Date + // differs, nothing accumulates. + std::map out(headers_); + + // Content-Length is the body's truth, full stop. Overwriting any + // caller-set value here is deliberate: a length that disagrees with the + // body is the response-smuggling bug, not a feature. + out["content-length"] = toString(body_.size()); + + // Date is auto-filled only if the caller didn't pin one (a CGI script in + // 3.3 may pass its own through). time(NULL) is the live clock; httpDate + // formats it locale-independently. On the impossible out-of-range case it + // returns "" -- skip the header entirely then rather than emit "Date:". + if (out.find("date") == out.end()) { + std::string date = httpDate(std::time(NULL)); + if (!date.empty()) + out["date"] = date; + } + + std::ostringstream os; + os << "HTTP/1.1 " << status_ << ' ' << reason_ << "\r\n"; + + // std::map walks keys in sorted order; order among distinct header names + // is meaningless in HTTP, so I don't fight it. + for (std::map::const_iterator it = out.begin(); + it != out.end(); ++it) { + os << canonicalHeaderName(it->first) << ": " << it->second << "\r\n"; + } + + os << "\r\n"; // blank line: end of head, start of body + os << body_; + return os.str(); +} + +std::string Response::httpDate(std::time_t t) { + // IMF-fixdate (RFC 7231 7.1.1.1): "Sun, 06 Nov 1994 08:49:37 GMT". The + // names are spelled out by hand -- strftime's %a/%b follow the locale, + // and an evaluator on a French box would otherwise get "dim."/"nov.". + static const char* const days[] = + { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; + static const char* const months[] = + { "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + + std::tm* g = std::gmtime(&t); + if (g == NULL) + return ""; // out-of-range time_t: no crash, just no Date (serialize + // still emits a valid message). Can't happen for the + // time(NULL) serialize() feeds it, but gmtime is allowed + // to fail and the subject's no-crash rule is absolute. + + std::ostringstream os; + os << days[g->tm_wday] << ", " + << std::setfill('0') + << std::setw(2) << g->tm_mday << ' ' + << months[g->tm_mon] << ' ' + << (g->tm_year + 1900) << ' ' + << std::setw(2) << g->tm_hour << ':' + << std::setw(2) << g->tm_min << ':' + << std::setw(2) << g->tm_sec << " GMT"; + return os.str(); +} + +std::string Response::reasonPhrase(int code) { + // Only the codes this server actually emits across the phases. Anything + // else returns "" and the caller is expected to pass an explicit reason + // via the two-arg setStatusCode. + switch (code) { + case 200: return "OK"; + case 201: return "Created"; + case 204: return "No Content"; + case 301: return "Moved Permanently"; + case 302: return "Found"; + case 304: return "Not Modified"; + case 400: return "Bad Request"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 408: return "Request Timeout"; + case 411: return "Length Required"; + case 413: return "Payload Too Large"; + case 414: return "URI Too Long"; + case 431: return "Request Header Fields Too Large"; + case 500: return "Internal Server Error"; + case 501: return "Not Implemented"; + case 504: return "Gateway Timeout"; + case 505: return "HTTP Version Not Supported"; + default: return ""; + } +} diff --git a/src/main.cpp b/src/main.cpp index 017007a..06de297 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,4 @@ #include "../includes/webserv.hpp" -#include -#include // --- DEV-ONLY diagnostic, remove before submission (Phase 4 cleanup) ------ // if I set LEXER_DUMP=, dump every token of that file to stderr and bail. @@ -17,15 +15,13 @@ static const char* kindName(TokenKind k) { } static int dumpTokens(const std::string& path) { - std::ifstream in(path.c_str()); - if (!in) { + std::string source; + if (!readFileToString(path, source)) { std::cerr << "cannot open " << path << std::endl; return 1; } - std::stringstream ss; - ss << in.rdbuf(); - Lexer lex(ss.str(), path); + Lexer lex(source, path); while (true) { const Token& t = lex.next(); std::cerr << "L" << t.line << " " << kindName(t.kind) diff --git a/tests/unit/test_response.cpp b/tests/unit/test_response.cpp new file mode 100644 index 0000000..12a71ea --- /dev/null +++ b/tests/unit/test_response.cpp @@ -0,0 +1,187 @@ +#include "../../includes/http.hpp" +#include +#include + +// Same framework-free style as test_request.cpp -- a check() counter is all +// a builder needs, and the subject bans external libs. Prints only failures; +// exit code feeds `make unit`. +static int g_failed = 0; +static int g_passed = 0; + +static void check(bool cond, const std::string& name) { + if (cond) { ++g_passed; return; } + ++g_failed; + std::cerr << "FAIL: " << name << std::endl; +} + +// True when `hay` contains `needle` -- the readable spelling of find != npos, +// used all over the serialize() assertions. +static bool contains(const std::string& hay, const std::string& needle) { + return hay.find(needle) != std::string::npos; +} + +static void test_default_is_empty_200() { + // A fresh Response must already be a valid message: a handler that only + // fills a body shouldn't have to remember to say "200 OK". + Response r; + std::string out = r.serialize(); + check(out.compare(0, 17, "HTTP/1.1 200 OK\r\n") == 0, + "fresh response starts with 200 OK status line"); + check(contains(out, "Content-Length: 0\r\n"), "empty body -> length 0"); + check(contains(out, "\r\n\r\n"), "blank line terminates the head"); +} + +static void test_status_code_looks_up_reason() { + Response r; + r.setStatusCode(404); + check(contains(r.serialize(), "HTTP/1.1 404 Not Found\r\n"), + "404 fills in 'Not Found'"); +} + +static void test_status_code_explicit_reason() { + // The escape hatch: a code we don't name, or a CGI-dictated reason. + Response r; + r.setStatusCode(418, "I'm a teapot"); + check(contains(r.serialize(), "HTTP/1.1 418 I'm a teapot\r\n"), + "explicit reason overrides the table"); +} + +static void test_set_header_appears() { + Response r; + r.setHeader("Content-Type", "text/html"); + check(contains(r.serialize(), "Content-Type: text/html\r\n"), + "a set header is emitted verbatim"); +} + +static void test_header_case_insensitive_overwrite() { + // Two spellings of the same name are ONE header (RFC 7230 3.2), and the + // output casing is canonical regardless of how it went in. + Response r; + r.setHeader("content-type", "text/plain"); + r.setHeader("Content-Type", "application/json"); + std::string out = r.serialize(); + check(contains(out, "Content-Type: application/json\r\n"), + "second set wins, name canonicalized"); + check(!contains(out, "text/plain"), "first value gone, no duplicate header"); +} + +static void test_body_after_blank_line() { + Response r; + r.setBody("hello"); + std::string out = r.serialize(); + std::size_t sep = out.find("\r\n\r\n"); + check(sep != std::string::npos, "head/body separator present"); + check(out.substr(sep + 4) == "hello", "body sits right after the blank line"); +} + +static void test_content_length_tracks_body() { + Response r; + r.setBody("12345"); + check(r.getContentLength() == 5, "getContentLength == body size"); + check(contains(r.serialize(), "Content-Length: 5\r\n"), + "auto Content-Length matches body"); +} + +static void test_content_length_is_authoritative() { + // Body is the source of truth: a wrong caller-set length must NOT be able + // to desync framing (smuggling vector). serialize() overrides it. + Response r; + r.setBody("abc"); + r.setHeader("Content-Length", "999"); + std::string out = r.serialize(); + check(contains(out, "Content-Length: 3\r\n"), "real body length wins"); + check(!contains(out, "Content-Length: 999\r\n"), "bogus length dropped"); +} + +static void test_date_auto_present_and_formed() { + Response r; + std::string out = r.serialize(); + check(contains(out, "Date: "), "Date auto-added"); + check(contains(out, " GMT\r\n"), "Date ends in GMT"); +} + +static void test_date_not_overwritten() { + // If the caller pinned a Date (e.g. a CGI passthrough), keep theirs. + Response r; + r.setHeader("Date", "Mon, 01 Jan 2024 00:00:00 GMT"); + std::string out = r.serialize(); + check(contains(out, "Date: Mon, 01 Jan 2024 00:00:00 GMT\r\n"), + "caller-set Date preserved"); +} + +static void test_http_date_epoch() { + // Epoch 0 is a Thursday -- a fixed, locale-independent anchor. + check(Response::httpDate(0) == "Thu, 01 Jan 1970 00:00:00 GMT", + "httpDate(0) is the canonical epoch string"); +} + +static void test_reason_phrase_table() { + check(Response::reasonPhrase(200) == "OK", "200 -> OK"); + check(Response::reasonPhrase(404) == "Not Found", "404 -> Not Found"); + check(Response::reasonPhrase(500) == "Internal Server Error", + "500 -> Internal Server Error"); + check(Response::reasonPhrase(799) == "", "unknown code -> empty phrase"); +} + +static void test_header_rejects_crlf_injection() { + // Response splitting: a value carrying CRLF must never reach the wire, or + // a CGI passthrough in 3.3 could smuggle in its own headers / fake body. + // The whole poisoned header is dropped, mirroring how the request parser + // 400s a CTL-bearing field rather than trying to clean it. + Response r; + r.setHeader("X-Test", "ok\r\nInjected: evil"); + std::string out = r.serialize(); + check(!contains(out, "Injected: evil"), "CRLF value can't inject a header"); + check(!contains(out, "X-Test"), "the poisoned header is dropped whole"); +} + +static void test_header_rejects_bad_name() { + // A space, colon or CTL in the NAME breaks framing just as badly. + Response r; + r.setHeader("Bad Name", "v"); // space + r.setHeader("Bad:Name", "v"); // colon + r.setHeader("Bad\r\nName", "v"); // CRLF + check(!contains(r.serialize(), "Bad"), "invalid header names dropped"); +} + +static void test_header_allows_tab_in_value() { + // HTAB is legal inside a field value (RFC 7230), and the request parser + // keeps it -- so the builder must not over-reject and drop a valid header. + Response r; + r.setHeader("X-Tab", "a\tb"); + check(contains(r.serialize(), "X-Tab: a\tb\r\n"), "tab preserved in value"); +} + +static void test_reason_phrase_strips_ctl() { + // CR/LF in the reason would split the status line itself. Fall back to the + // table phrase for the code rather than echoing attacker bytes. + Response r; + r.setStatusCode(404, "Not Found\r\nX-Evil: 1"); + std::string out = r.serialize(); + check(out.compare(0, 24, "HTTP/1.1 404 Not Found\r\n") == 0, + "CTL reason falls back to the table phrase"); + check(!contains(out, "X-Evil"), "no injection through the reason phrase"); +} + +int main() { + test_default_is_empty_200(); + test_status_code_looks_up_reason(); + test_status_code_explicit_reason(); + test_set_header_appears(); + test_header_case_insensitive_overwrite(); + test_body_after_blank_line(); + test_content_length_tracks_body(); + test_content_length_is_authoritative(); + test_date_auto_present_and_formed(); + test_date_not_overwritten(); + test_http_date_epoch(); + test_reason_phrase_table(); + test_header_rejects_crlf_injection(); + test_header_rejects_bad_name(); + test_header_allows_tab_in_value(); + test_reason_phrase_strips_ctl(); + + std::cout << g_passed << " passed, " << g_failed << " failed" + << std::endl; + return g_failed == 0 ? 0 : 1; +}