diff --git a/httplib.h b/httplib.h index c0fbbf5..31f6ba3 100644 --- a/httplib.h +++ b/httplib.h @@ -1011,17 +1011,6 @@ using ErrorLogger = std::function; using SocketOptions = std::function; -namespace detail { - -bool set_socket_opt_impl(socket_t sock, int level, int optname, - const void *optval, socklen_t optlen); -bool set_socket_opt(socket_t sock, int level, int optname, int opt); -bool set_socket_opt_time(socket_t sock, int level, int optname, time_t sec, - time_t usec); -int close_socket(socket_t sock); - -} // namespace detail - void default_socket_options(socket_t sock); const char *status_message(int status); @@ -1102,10 +1091,9 @@ private: std::regex regex_; }; -ssize_t write_headers(Stream &strm, const Headers &headers); +int close_socket(socket_t sock); -std::string make_host_and_port_string(const std::string &host, int port, - bool is_ssl); +ssize_t write_headers(Stream &strm, const Headers &headers); } // namespace detail @@ -1257,7 +1245,11 @@ private: bool listen_internal(); bool routing(Request &req, Response &res, Stream &strm); - bool handle_file_request(const Request &req, Response &res); + bool handle_file_request(Request &req, Response &res); + bool check_if_not_modified(const Request &req, Response &res, + const std::string &etag, time_t mtime) const; + bool check_if_range(Request &req, const std::string &etag, + time_t mtime) const; bool dispatch_request(Request &req, Response &res, const Handlers &handlers) const; bool dispatch_request_for_content_reader( @@ -2593,6 +2585,8 @@ struct FileStat { FileStat(const std::string &path); bool is_file() const; bool is_dir() const; + time_t mtime() const; + size_t size() const; private: #if defined(_WIN32) @@ -2603,6 +2597,9 @@ private: int ret_ = -1; }; +std::string make_host_and_port_string(const std::string &host, int port, + bool is_ssl); + std::string trim_copy(const std::string &s); void divide( @@ -2971,6 +2968,90 @@ inline std::string from_i_to_hex(size_t n) { return ret; } +inline std::string compute_etag(const FileStat &fs) { + if (!fs.is_file()) { return std::string(); } + + // If mtime cannot be determined (negative value indicates an error + // or sentinel), do not generate an ETag. Returning a neutral / fixed + // value like 0 could collide with a real file that legitimately has + // mtime == 0 (epoch) and lead to misleading validators. + auto mtime_raw = fs.mtime(); + if (mtime_raw < 0) { return std::string(); } + + auto mtime = static_cast(mtime_raw); + auto size = fs.size(); + + return std::string("W/\"") + from_i_to_hex(mtime) + "-" + + from_i_to_hex(size) + "\""; +} + +// Format time_t as HTTP-date (RFC 9110 Section 5.6.7): "Sun, 06 Nov 1994 +// 08:49:37 GMT" This implementation is defensive: it validates `mtime`, checks +// return values from `gmtime_r`/`gmtime_s`, and ensures `strftime` succeeds. +inline std::string file_mtime_to_http_date(time_t mtime) { + if (mtime < 0) { return std::string(); } + + struct tm tm_buf; +#ifdef _WIN32 + if (gmtime_s(&tm_buf, &mtime) != 0) { return std::string(); } +#else + if (gmtime_r(&mtime, &tm_buf) == nullptr) { return std::string(); } +#endif + char buf[64]; + if (strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf) == 0) { + return std::string(); + } + + return std::string(buf); +} + +// Parse HTTP-date (RFC 9110 Section 5.6.7) to time_t. Returns -1 on failure. +inline time_t parse_http_date(const std::string &date_str) { + struct tm tm_buf; + + // Create a classic locale object once for all parsing attempts + const std::locale classic_locale = std::locale::classic(); + + // Try to parse using std::get_time (C++11, cross-platform) + auto try_parse = [&](const char *fmt) -> bool { + std::istringstream ss(date_str); + ss.imbue(classic_locale); + + memset(&tm_buf, 0, sizeof(tm_buf)); + ss >> std::get_time(&tm_buf, fmt); + + return !ss.fail(); + }; + + // RFC 9110 preferred format (HTTP-date): "Sun, 06 Nov 1994 08:49:37 GMT" + if (!try_parse("%a, %d %b %Y %H:%M:%S")) { + // RFC 850 format: "Sunday, 06-Nov-94 08:49:37 GMT" + if (!try_parse("%A, %d-%b-%y %H:%M:%S")) { + // asctime format: "Sun Nov 6 08:49:37 1994" + if (!try_parse("%a %b %d %H:%M:%S %Y")) { + return static_cast(-1); + } + } + } + +#ifdef _WIN32 + return _mkgmtime(&tm_buf); +#else + return timegm(&tm_buf); +#endif +} + +inline bool is_weak_etag(const std::string &s) { + // Check if the string is a weak ETag (starts with 'W/"') + return s.size() > 3 && s[0] == 'W' && s[1] == '/' && s[2] == '"'; +} + +inline bool is_strong_etag(const std::string &s) { + // Check if the string is a strong ETag (starts and ends with '"', at least 2 + // chars) + return s.size() >= 2 && s[0] == '"' && s.back() == '"'; +} + inline size_t to_utf8(int code, char *buff) { if (code < 0x0080) { buff[0] = static_cast(code & 0x7F); @@ -3090,6 +3171,15 @@ inline bool FileStat::is_dir() const { return ret_ >= 0 && S_ISDIR(st_.st_mode); } +inline time_t FileStat::mtime() const { + return ret_ >= 0 ? static_cast(st_.st_mtime) + : static_cast(-1); +} + +inline size_t FileStat::size() const { + return ret_ >= 0 ? static_cast(st_.st_size) : 0; +} + inline std::string encode_path(const std::string &s) { std::string result; result.reserve(s.size()); @@ -3345,6 +3435,42 @@ inline void split(const char *b, const char *e, char d, size_t m, } } +inline bool split_find(const char *b, const char *e, char d, size_t m, + std::function fn) { + size_t i = 0; + size_t beg = 0; + size_t count = 1; + + while (e ? (b + i < e) : (b[i] != '\0')) { + if (b[i] == d && count < m) { + auto r = trim(b, e, beg, i); + if (r.first < r.second) { + auto found = fn(&b[r.first], &b[r.second]); + if (found) { return true; } + } + beg = i + 1; + count++; + } + i++; + } + + if (i) { + auto r = trim(b, e, beg, i); + if (r.first < r.second) { + auto found = fn(&b[r.first], &b[r.second]); + if (found) { return true; } + } + } + + return false; +} + +inline bool split_find(const char *b, const char *e, char d, + std::function fn) { + return split_find(b, e, d, (std::numeric_limits::max)(), + std::move(fn)); +} + inline stream_line_reader::stream_line_reader(Stream &strm, char *fixed_buffer, size_t fixed_buffer_size) : strm_(strm), fixed_buffer_(fixed_buffer), @@ -8256,7 +8382,7 @@ inline bool Server::read_content_core( return true; } -inline bool Server::handle_file_request(const Request &req, Response &res) { +inline bool Server::handle_file_request(Request &req, Response &res) { for (const auto &entry : base_dirs_) { // Prefix match if (!req.path.compare(0, entry.mount_point.size(), entry.mount_point)) { @@ -8277,6 +8403,20 @@ inline bool Server::handle_file_request(const Request &req, Response &res) { res.set_header(kv.first, kv.second); } + auto etag = detail::compute_etag(stat); + if (!etag.empty()) { res.set_header("ETag", etag); } + + auto mtime = stat.mtime(); + + auto last_modified = detail::file_mtime_to_http_date(mtime); + if (!last_modified.empty()) { + res.set_header("Last-Modified", last_modified); + } + + if (check_if_not_modified(req, res, etag, mtime)) { return true; } + + check_if_range(req, etag, mtime); + auto mm = std::make_shared(path.c_str()); if (!mm->is_open()) { output_error_log(Error::OpenFile, &req); @@ -8306,6 +8446,79 @@ inline bool Server::handle_file_request(const Request &req, Response &res) { return false; } +inline bool Server::check_if_not_modified(const Request &req, Response &res, + const std::string &etag, + time_t mtime) const { + // Handle conditional GET: + // 1. If-None-Match takes precedence (RFC 9110 Section 13.1.2) + // 2. If-Modified-Since is checked only when If-None-Match is absent + if (req.has_header("If-None-Match")) { + if (!etag.empty()) { + auto val = req.get_header_value("If-None-Match"); + + // NOTE: We use exact string matching here. This works correctly + // because our server always generates weak ETags (W/"..."), and + // clients typically send back the same ETag they received. + // RFC 9110 Section 8.8.3.2 allows weak comparison for + // If-None-Match, where W/"x" and "x" would match, but this + // simplified implementation requires exact matches. + auto ret = detail::split_find(val.data(), val.data() + val.size(), ',', + [&](const char *b, const char *e) { + return std::equal(b, e, "*") || + std::equal(b, e, etag.begin()); + }); + + if (ret) { + res.status = StatusCode::NotModified_304; + return true; + } + } + } else if (req.has_header("If-Modified-Since")) { + auto val = req.get_header_value("If-Modified-Since"); + auto t = detail::parse_http_date(val); + + if (t != static_cast(-1) && mtime <= t) { + res.status = StatusCode::NotModified_304; + return true; + } + } + return false; +} + +inline bool Server::check_if_range(Request &req, const std::string &etag, + time_t mtime) const { + // Handle If-Range for partial content requests (RFC 9110 + // Section 13.1.5). If-Range is only evaluated when Range header is + // present. If the validator matches, serve partial content; otherwise + // serve full content. + if (!req.ranges.empty() && req.has_header("If-Range")) { + auto val = req.get_header_value("If-Range"); + + auto is_valid_range = [&]() { + if (detail::is_strong_etag(val)) { + // RFC 9110 Section 13.1.5: If-Range requires strong ETag + // comparison. + return (!etag.empty() && val == etag); + } else if (detail::is_weak_etag(val)) { + // Weak ETags are not valid for If-Range (RFC 9110 Section 13.1.5) + return false; + } else { + // HTTP-date comparison + auto t = detail::parse_http_date(val); + return (t != static_cast(-1) && mtime <= t); + } + }; + + if (!is_valid_range()) { + // Validator doesn't match: ignore Range and serve full content + req.ranges.clear(); + return false; + } + } + + return true; +} + inline socket_t Server::create_server_socket(const std::string &host, int port, int socket_flags, @@ -8573,10 +8786,13 @@ inline void Server::apply_ranges(const Request &req, Response &res, res.set_header("Transfer-Encoding", "chunked"); if (type == detail::EncodingType::Gzip) { res.set_header("Content-Encoding", "gzip"); + res.set_header("Vary", "Accept-Encoding"); } else if (type == detail::EncodingType::Brotli) { res.set_header("Content-Encoding", "br"); + res.set_header("Vary", "Accept-Encoding"); } else if (type == detail::EncodingType::Zstd) { res.set_header("Content-Encoding", "zstd"); + res.set_header("Vary", "Accept-Encoding"); } } } @@ -8635,6 +8851,7 @@ inline void Server::apply_ranges(const Request &req, Response &res, })) { res.body.swap(compressed); res.set_header("Content-Encoding", content_encoding); + res.set_header("Vary", "Accept-Encoding"); } } } diff --git a/test/test.cc b/test/test.cc index b6e93d9..9001aae 100644 --- a/test/test.cc +++ b/test/test.cc @@ -4,9 +4,11 @@ #ifndef _WIN32 #include +#include #include #include #include +#include #include #endif #include @@ -12687,3 +12689,536 @@ TEST(ErrorHandlingTest, SSLStreamConnectionClosed) { t.join(); } #endif + +TEST(ETagTest, StaticFileETagAndIfNoneMatch) { + using namespace httplib; + + // Create a test file + const char *fname = "etag_testfile.txt"; + const char *content = "etag-content"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8087); }); + svr.wait_until_ready(); + + Client cli("localhost", 8087); + + // First request: should get 200 with ETag header + auto res1 = cli.Get("/static/etag_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + std::string etag = res1->get_header_value("ETag"); + EXPECT_FALSE(etag.empty()); + + // Verify ETag format: W/"hex-hex" + ASSERT_GE(etag.length(), 5u); // Minimum: W/"" + EXPECT_EQ('W', etag[0]); + EXPECT_EQ('/', etag[1]); + EXPECT_EQ('"', etag[2]); + EXPECT_EQ('"', etag.back()); + + // Exact match: expect 304 Not Modified + Headers h2 = {{"If-None-Match", etag}}; + auto res2 = cli.Get("/static/etag_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(304, res2->status); + + // Wildcard match: expect 304 Not Modified + Headers h3 = {{"If-None-Match", "*"}}; + auto res3 = cli.Get("/static/etag_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(304, res3->status); + + // Non-matching ETag: expect 200 + Headers h4 = {{"If-None-Match", "W/\"deadbeef\""}}; + auto res4 = cli.Get("/static/etag_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(200, res4->status); + + // Multiple ETags with one matching: expect 304 + Headers h5 = {{"If-None-Match", "W/\"other\", " + etag + ", W/\"another\""}}; + auto res5 = cli.Get("/static/etag_testfile.txt", h5); + ASSERT_TRUE(res5); + EXPECT_EQ(304, res5->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, StaticFileETagIfNoneMatchStarNotFound) { + using namespace httplib; + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8090); }); + svr.wait_until_ready(); + + Client cli("localhost", 8090); + + // Send If-None-Match: * to a non-existent file + Headers h = {{"If-None-Match", "*"}}; + auto res = cli.Get("/static/etag_testfile_notfound.txt", h); + ASSERT_TRUE(res); + EXPECT_EQ(404, res->status); + + svr.stop(); + t.join(); +} + +TEST(ETagTest, LastModifiedAndIfModifiedSince) { + using namespace httplib; + + // Create a test file + const char *fname = "ims_testfile.txt"; + const char *content = "if-modified-since-test"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8088); }); + svr.wait_until_ready(); + + Client cli("localhost", 8088); + + // First request: should get 200 with Last-Modified header + auto res1 = cli.Get("/static/ims_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("Last-Modified")); + std::string last_modified = res1->get_header_value("Last-Modified"); + EXPECT_FALSE(last_modified.empty()); + + // If-Modified-Since with same time: expect 304 + Headers h2 = {{"If-Modified-Since", last_modified}}; + auto res2 = cli.Get("/static/ims_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(304, res2->status); + + // If-Modified-Since with future time: expect 304 + Headers h3 = {{"If-Modified-Since", "Sun, 01 Jan 2099 00:00:00 GMT"}}; + auto res3 = cli.Get("/static/ims_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(304, res3->status); + + // If-Modified-Since with past time: expect 200 + Headers h4 = {{"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}}; + auto res4 = cli.Get("/static/ims_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(200, res4->status); + + // If-None-Match takes precedence over If-Modified-Since + // (send matching ETag with old If-Modified-Since -> should still be 304) + ASSERT_TRUE(res1->has_header("ETag")); + std::string etag = res1->get_header_value("ETag"); + Headers h5 = {{"If-None-Match", etag}, + {"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}}; + auto res5 = cli.Get("/static/ims_testfile.txt", h5); + ASSERT_TRUE(res5); + EXPECT_EQ(304, res5->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, VaryAcceptEncodingWithCompression) { + using namespace httplib; + + Server svr; + + // Endpoint that returns compressible content + svr.Get("/compressible", [](const Request &, Response &res) { + // Return a large enough body to trigger compression + std::string body(1000, 'a'); + res.set_content(body, "text/plain"); + }); + + auto t = std::thread([&]() { svr.listen("localhost", 8089); }); + svr.wait_until_ready(); + + Client cli("localhost", 8089); + + // Request with gzip support: should get Vary header when compressed + cli.set_compress(true); + auto res1 = cli.Get("/compressible"); + ASSERT_TRUE(res1); + EXPECT_EQ(200, res1->status); + + // If Content-Encoding is set, Vary should also be set + if (res1->has_header("Content-Encoding")) { + EXPECT_TRUE(res1->has_header("Vary")); + EXPECT_EQ("Accept-Encoding", res1->get_header_value("Vary")); + } + + // Request without Accept-Encoding header: should not have compression + Headers h_no_compress; + auto res2 = cli.Get("/compressible", h_no_compress); + ASSERT_TRUE(res2); + EXPECT_EQ(200, res2->status); + + // Verify Vary header is present when compression is applied + // (the exact behavior depends on server configuration) + + svr.stop(); + t.join(); +} + +TEST(ETagTest, IfRangeWithETag) { + using namespace httplib; + + // Create a test file with known content + const char *fname = "if_range_testfile.txt"; + const std::string content = "0123456789ABCDEFGHIJ"; // 20 bytes + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8090); }); + svr.wait_until_ready(); + + Client cli("localhost", 8090); + + // First request: get ETag + auto res1 = cli.Get("/static/if_range_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + std::string etag = res1->get_header_value("ETag"); + + // RFC 9110 Section 13.1.5: If-Range requires strong ETag comparison. + // Since our server generates weak ETags (W/"..."), If-Range with our + // ETag should NOT result in partial content - it should return full content. + Headers h2 = {{"Range", "bytes=0-4"}, {"If-Range", etag}}; + auto res2 = cli.Get("/static/if_range_testfile.txt", h2); + ASSERT_TRUE(res2); + // Weak ETag in If-Range -> full content (200), not partial (206) + EXPECT_EQ(200, res2->status); + EXPECT_EQ(content, res2->body); + EXPECT_FALSE(res2->has_header("Content-Range")); + + // Range request with non-matching If-Range (ETag): should get 200 (full + // content) + Headers h3 = {{"Range", "bytes=0-4"}, {"If-Range", "W/\"wrong-etag\""}}; + auto res3 = cli.Get("/static/if_range_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(200, res3->status); + EXPECT_EQ(content, res3->body); + EXPECT_FALSE(res3->has_header("Content-Range")); + + // Range request with strong ETag (hypothetical - our server doesn't generate + // strong ETags, but if client sends a strong ETag that doesn't match, it + // should return full content) + Headers h4 = {{"Range", "bytes=0-4"}, {"If-Range", "\"strong-etag\""}}; + auto res4 = cli.Get("/static/if_range_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(200, res4->status); + EXPECT_EQ(content, res4->body); + EXPECT_FALSE(res4->has_header("Content-Range")); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, IfRangeWithDate) { + using namespace httplib; + + // Create a test file + const char *fname = "if_range_date_testfile.txt"; + const std::string content = "ABCDEFGHIJ0123456789"; // 20 bytes + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8091); }); + svr.wait_until_ready(); + + Client cli("localhost", 8091); + + // First request: get Last-Modified + auto res1 = cli.Get("/static/if_range_date_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("Last-Modified")); + std::string last_modified = res1->get_header_value("Last-Modified"); + + // Range request with matching If-Range (date): should get 206 + Headers h2 = {{"Range", "bytes=5-9"}, {"If-Range", last_modified}}; + auto res2 = cli.Get("/static/if_range_date_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(206, res2->status); + EXPECT_EQ("FGHIJ", res2->body); + + // Range request with old If-Range date: should get 200 (full content) + Headers h3 = {{"Range", "bytes=5-9"}, + {"If-Range", "Sun, 01 Jan 2000 00:00:00 GMT"}}; + auto res3 = cli.Get("/static/if_range_date_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(200, res3->status); + EXPECT_EQ(content, res3->body); + + // Range request with future If-Range date: should get 206 + Headers h4 = {{"Range", "bytes=0-4"}, + {"If-Range", "Sun, 01 Jan 2099 00:00:00 GMT"}}; + auto res4 = cli.Get("/static/if_range_date_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(206, res4->status); + EXPECT_EQ("ABCDE", res4->body); + + svr.stop(); + t.join(); + std::remove(fname); +} +TEST(ETagTest, MalformedIfNoneMatchAndWhitespace) { + using namespace httplib; + + const char *fname = "etag_malformed.txt"; + const char *content = "malformed-etag"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8092); }); + svr.wait_until_ready(); + + Client cli("localhost", 8092); + + // baseline: should get 200 and an ETag + auto res1 = cli.Get("/static/etag_malformed.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + + // Malformed ETag value (missing quotes) should be treated as non-matching + Headers h_bad = {{"If-None-Match", "W/noquotes"}}; + auto res_bad = cli.Get("/static/etag_malformed.txt", h_bad); + ASSERT_TRUE(res_bad); + EXPECT_EQ(200, res_bad->status); + + // Whitespace-only header value should be considered invalid / non-matching + Headers h_space = {{"If-None-Match", " "}}; + auto res_space = cli.Get("/static/etag_malformed.txt", h_space); + ASSERT_TRUE(res_space); + EXPECT_EQ(200, res_space->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, InvalidIfModifiedSinceAndIfRangeDate) { + using namespace httplib; + + const char *fname = "ims_invalid_format.txt"; + const char *content = "ims-bad-format"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8093); }); + svr.wait_until_ready(); + + Client cli("localhost", 8093); + + auto res1 = cli.Get("/static/ims_invalid_format.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("Last-Modified")); + + // If-Modified-Since with invalid format should not result in 304 + Headers h_bad_date = {{"If-Modified-Since", "not-a-valid-date"}}; + auto res_bad = cli.Get("/static/ims_invalid_format.txt", h_bad_date); + ASSERT_TRUE(res_bad); + EXPECT_EQ(200, res_bad->status); + + // If-Range with invalid date format should be treated as mismatch -> full + // content (200) + Headers h_ifrange_bad = {{"Range", "bytes=0-3"}, + {"If-Range", "invalid-date"}}; + auto res_ifrange = cli.Get("/static/ims_invalid_format.txt", h_ifrange_bad); + ASSERT_TRUE(res_ifrange); + EXPECT_EQ(200, res_ifrange->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, IfRangeWithMalformedETag) { + using namespace httplib; + + const char *fname = "ifrange_malformed.txt"; + const std::string content = "0123456789"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8094); }); + svr.wait_until_ready(); + + Client cli("localhost", 8094); + + // First request: get ETag + auto res1 = cli.Get("/static/ifrange_malformed.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + + // If-Range with malformed ETag (no quotes) should be treated as mismatch -> + // full content (200) + Headers h_malformed = {{"Range", "bytes=0-4"}, {"If-Range", "W/noquotes"}}; + auto res2 = cli.Get("/static/ifrange_malformed.txt", h_malformed); + ASSERT_TRUE(res2); + EXPECT_EQ(200, res2->status); + EXPECT_EQ(content, res2->body); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, ExtremeLargeDateValues) { + using namespace httplib; + + const char *fname = "ims_extreme_date.txt"; + const char *content = "ims-extreme-date"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8095); }); + svr.wait_until_ready(); + + Client cli("localhost", 8095); + + auto res1 = cli.Get(std::string("/static/") + fname); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("Last-Modified")); + + // Extremely large year that may overflow date parsing routines. + Headers h_large_date = { + {"If-Modified-Since", "Sun, 01 Jan 99999 00:00:00 GMT"}}; + auto res_bad = cli.Get(std::string("/static/") + fname, h_large_date); + ASSERT_TRUE(res_bad); + // Expect server to treat this as invalid/mismatch and return full content + EXPECT_EQ(200, res_bad->status); + + // If-Range with extremely large date should be treated as mismatch -> full + // content (200) + Headers h_ifrange_large = {{"Range", "bytes=0-3"}, + {"If-Range", "Sun, 01 Jan 99999 00:00:00 GMT"}}; + auto res_ifrange = cli.Get(std::string("/static/") + fname, h_ifrange_large); + ASSERT_TRUE(res_ifrange); + EXPECT_EQ(200, res_ifrange->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, NegativeFileModificationTime) { + using namespace httplib; + + const char *fname = "ims_negative_mtime.txt"; + const std::string content = "negative-mtime"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + // Try to set file mtime to a negative value. This may fail on some + // platforms/filesystems; if it fails, the test will still verify server + // behaves safely by performing a regular conditional request. +#if defined(__APPLE__) || defined(__linux__) + bool set_negative = false; + do { + struct timeval times[2]; + // access time: now + times[0].tv_sec = time(nullptr); + times[0].tv_usec = 0; + // modification time: negative (e.g., -1) + times[1].tv_sec = -1; + times[1].tv_usec = 0; + if (utimes(fname, times) == 0) { set_negative = true; } + } while (0); +#else + bool set_negative = false; +#endif + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8096); }); + svr.wait_until_ready(); + + Client cli("localhost", 8096); + + auto res1 = cli.Get(std::string("/static/") + fname); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + bool has_last_modified = res1->has_header("Last-Modified"); + std::string last_modified; + if (has_last_modified) { + last_modified = res1->get_header_value("Last-Modified"); + } + + if (set_negative) { + // If we successfully set a negative mtime, ensure server returns a + // Last-Modified string (may be empty or normalized). Send If-Modified-Since + // with an old date and ensure server handles it without crash. + Headers h_old = {{"If-Modified-Since", "Sun, 01 Jan 1970 00:00:00 GMT"}}; + auto res2 = cli.Get(std::string("/static/") + fname, h_old); + ASSERT_TRUE(res2); + // Behavior may vary; at minimum ensure server responds (200 or 304). + EXPECT_TRUE(res2->status == 200 || res2->status == 304); + } else { + // Could not set negative mtime on this platform; fall back to verifying + // that normal invalid/malformed dates are treated safely (non-304). + Headers h_bad_date = { + {"If-Modified-Since", "Sun, 01 Jan 99999 00:00:00 GMT"}}; + auto res_bad = cli.Get(std::string("/static/") + fname, h_bad_date); + ASSERT_TRUE(res_bad); + EXPECT_EQ(200, res_bad->status); + } + + svr.stop(); + t.join(); + std::remove(fname); +} \ No newline at end of file