diff --git a/.drone.star b/.drone.star index 27f5d63..54a6b27 100644 --- a/.drone.star +++ b/.drone.star @@ -23,13 +23,14 @@ def main(ctx): 'gcc >=13.0', 'clang >=17.0', # 'msvc >=14.1', - 'arm64-gcc latest', 'arm64-clang latest' # 'x86-msvc latest' ], '>=20', + cmake=False, docs=False, coverage=False, + asan=False, cache_dir='cache') # Note: liburing-dev is not added to generate()'s package list. # generate() emits jobs on Ubuntu focal (which has no liburing-dev @@ -60,33 +61,33 @@ def main(ctx): # globalenv=globalenv), # ] - jobs += [ - freebsd_cxx("clang-22", "clang++-22", - buildscript="drone", buildtype="boost", - freebsd_version="15.0", - environment={ - 'B2_TOOLSET': 'clang-22', - 'B2_CXXSTD': '20', - }, - globalenv=globalenv), - ] + # jobs += [ + # freebsd_cxx("clang-22", "clang++-22", + # buildscript="drone", buildtype="boost", + # freebsd_version="15.0", + # environment={ + # 'B2_TOOLSET': 'clang-22', + # 'B2_CXXSTD': '20', + # }, + # globalenv=globalenv), + # ] # Jobs not covered by generate() jobs += [ - linux_cxx("Valgrind", "clang++-17", packages="clang-17 libc6-dbg libstdc++-12-dev liburing-dev", - llvm_os="jammy", llvm_ver="17", - buildscript="drone", buildtype="valgrind", - image="cppalliance/droneubuntu2204:1", - environment={ - 'COMMENT': 'valgrind', - 'B2_TOOLSET': 'clang-17', - 'B2_CXXSTD': '20', - 'B2_DEFINES': 'BOOST_NO_STRESS_TEST=1', - 'B2_VARIANT': 'debug', - 'B2_TESTFLAGS': 'testing.launcher=valgrind', - 'VALGRIND_OPTS': '--error-exitcode=1', - }, - globalenv=globalenv), + # linux_cxx("Valgrind", "clang++-17", packages="clang-17 libc6-dbg libstdc++-12-dev liburing-dev", + # llvm_os="jammy", llvm_ver="17", + # buildscript="drone", buildtype="valgrind", + # image="cppalliance/droneubuntu2204:1", + # environment={ + # 'COMMENT': 'valgrind', + # 'B2_TOOLSET': 'clang-17', + # 'B2_CXXSTD': '20', + # 'B2_DEFINES': 'BOOST_NO_STRESS_TEST=1', + # 'B2_VARIANT': 'debug', + # 'B2_TESTFLAGS': 'testing.launcher=valgrind', + # 'VALGRIND_OPTS': '--error-exitcode=1', + # }, + # globalenv=globalenv), # Note: no liburing-dev on the Drone cmake jobs even though the # noble image has 2.5+. Docker's default seccomp profile blocks diff --git a/CMakeLists.txt b/CMakeLists.txt index edfdd5d..41fc9a1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -161,10 +161,6 @@ function(boost_burl_setup_properties target) target_include_directories(${target} PUBLIC "${PROJECT_SOURCE_DIR}/include") target_include_directories(${target} PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(${target} PUBLIC ${BOOST_BURL_DEPENDENCIES}) - # Link corosio_openssl for TLS support when available. - # In superproject builds, corosio is processed after burl (alphabetically), - # so the target may not exist at configure time. Use $ - # which evaluates at generation time when all targets exist. target_link_libraries(${target} PUBLIC $) target_compile_definitions(${target} PUBLIC BOOST_BURL_NO_LIB) target_compile_definitions(${target} PRIVATE BOOST_BURL_SOURCE) diff --git a/src/cookie_jar.cpp b/src/cookie_jar.cpp index dc6aa10..b42541c 100644 --- a/src/cookie_jar.cpp +++ b/src/cookie_jar.cpp @@ -40,9 +40,6 @@ domain_match( if(!tailmatch) return host == domain; - if(domain.starts_with('.')) - domain.remove_prefix(1); - if(host.ends_with(domain)) { if(host.size() == domain.size()) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index 70ecbdd..7fcb4e3 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -19,18 +19,11 @@ list(APPEND PFILES source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "" FILES ${PFILES}) add_executable(boost_burl_tests ${PFILES}) -target_link_libraries( - boost_burl_tests PRIVATE +target_link_libraries(boost_burl_tests PRIVATE boost_capy_test_suite_main - Boost::burl) - -if (TARGET Boost::http_zlib) - target_link_libraries(boost_burl_tests PRIVATE Boost::http_zlib) -endif() - -if (TARGET Boost::http_brotli) - target_link_libraries(boost_burl_tests PRIVATE Boost::http_brotli) -endif() + Boost::burl + $ + $) target_include_directories(boost_burl_tests PRIVATE . ../../) diff --git a/test/unit/client.cpp b/test/unit/client.cpp index 9b4c009..4e69a0e 100644 --- a/test/unit/client.cpp +++ b/test/unit/client.cpp @@ -719,7 +719,8 @@ class client_test capy::test::run_blocking()([&]() -> capy::task<> { auto cfg = net.config(); - cfg.gzip = true; + cfg.gzip = true; + cfg.deflate = true; client c( co_await capy::this_coro::executor, corosio::tls_context(), @@ -736,7 +737,7 @@ class client_test }()); BOOST_TEST( - net.written(0).find("Accept-Encoding: gzip\r\n") != + net.written(0).find("Accept-Encoding: deflate, gzip\r\n") != std::string::npos); } diff --git a/test/unit/cookie_jar.cpp b/test/unit/cookie_jar.cpp index 7e8fcc1..d8ead3f 100644 --- a/test/unit/cookie_jar.cpp +++ b/test/unit/cookie_jar.cpp @@ -14,8 +14,6 @@ #include -#include - namespace boost { namespace burl @@ -23,6 +21,10 @@ namespace burl struct cookie_jar_test { + // + // Adding, replacing, and header ordering + // + void testAddAndHeader() { @@ -35,76 +37,6 @@ struct cookie_jar_test BOOST_TEST_EQ(jar.cookie_header(url), "id=42; theme=dark"); } - void - testSecure() - { - cookie_jar jar; - urls::url https("https://example.com/"); - jar.add(https, parse_cookie("s=1; Secure").value()); - - BOOST_TEST_EQ(jar.cookie_header(https), "s=1"); - - urls::url http("http://example.com/"); - BOOST_TEST_EQ(jar.cookie_header(http), ""); - } - - void - testDomainMismatch() - { - cookie_jar jar; - urls::url url("https://example.com/"); - - jar.add(url, parse_cookie("x=1; Domain=other.com").value()); - BOOST_TEST_EQ(jar.cookie_header(url), ""); - } - - void - testPublicSuffix() - { - // A registrable domain is accepted. - { - cookie_jar jar; - urls::url url("https://www.example.com/"); - jar.add(url, parse_cookie("a=1; Domain=example.com").value()); - BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); - } - - // A bare TLD is rejected, by libpsl and the no-dot fallback alike. - { - cookie_jar jar; - urls::url url("https://example.com/"); - jar.add(url, parse_cookie("a=1; Domain=com").value()); - BOOST_TEST_EQ(jar.cookie_header(url), ""); - } - - // RFC 6265bis 5.6.3: a leading dot is ignored — accepted on a domain, - // rejected on a bare TLD. - { - cookie_jar jar; - urls::url url("https://www.example.com/"); - jar.add(url, parse_cookie("a=1; Domain=.example.com").value()); - BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); - } - { - cookie_jar jar; - urls::url url("https://example.com/"); - jar.add(url, parse_cookie("a=1; Domain=.com").value()); - BOOST_TEST_EQ(jar.cookie_header(url), ""); - } - - // A multi-label public suffix is rejected only with libpsl; the - // no-dot fallback can't tell and accepts it. - { - cookie_jar jar; - urls::url url("https://example.co.uk/"); - jar.add(url, parse_cookie("a=1; Domain=co.uk").value()); - if(cookie_jar::public_suffix_supported()) - BOOST_TEST_EQ(jar.cookie_header(url), ""); - else - BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); - } - } - void testReplace() { @@ -123,35 +55,6 @@ struct cookie_jar_test "k=new; d=new"); } - void - testPathMatch() - { - cookie_jar jar; - jar.add( - urls::url("https://example.com/app"), - parse_cookie("k=1; Path=/app").value()); - - // RFC 6265bis 5.1.4: the cookie path is a prefix ending on a boundary. - BOOST_TEST_EQ(jar.cookie_header(urls::url("https://example.com/app")), "k=1"); - BOOST_TEST_EQ( - jar.cookie_header(urls::url("https://example.com/app/x")), "k=1"); - - // A prefix that is not on a path boundary does not match. - BOOST_TEST_EQ( - jar.cookie_header(urls::url("https://example.com/application")), ""); - - // RFC 6265bis 5.1.4: a no-path request defaults to "/", not matching /app. - BOOST_TEST_EQ( - jar.cookie_header(urls::url("https://example.com")), ""); - - // RFC 6265bis 5.1.4: the defaulted "/" does match a root cookie. - cookie_jar root; - root.add( - urls::url("https://example.com/"), parse_cookie("a=1").value()); - BOOST_TEST_EQ( - root.cookie_header(urls::url("https://example.com")), "a=1"); - } - void testOrdering() { @@ -194,55 +97,18 @@ struct cookie_jar_test } } + // + // Domain matching + // + void - testLocalhostSecure() + testDomainMismatch() { - // localhost is a secure context, so Secure cookies are accepted and - // sent over plain http (matches curl/browsers). - { - cookie_jar jar; - urls::url url("http://localhost/"); - jar.add(url, parse_cookie("s=1; Secure").value()); - BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); - } - - // The same holds for loopback addresses. - { - cookie_jar jar; - urls::url url("http://127.0.0.1/"); - jar.add(url, parse_cookie("s=1; Secure").value()); - BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); - } - { - cookie_jar jar; - urls::url url("http://127.0.0.255/"); - jar.add(url, parse_cookie("s=1; Secure").value()); - BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); - } - { - cookie_jar jar; - urls::url url("http://[::1]/"); - jar.add(url, parse_cookie("s=1; Secure").value()); - BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); - } - - // "localhost." is not a secure context: fail closed rather than - // normalize the trailing dot (matches curl's literal check). - { - cookie_jar jar; - urls::url url("http://localhost./"); - jar.add(url, parse_cookie("s=1; Secure").value()); - BOOST_TEST_EQ(jar.cookie_header(url), ""); - } + cookie_jar jar; + urls::url url("https://example.com/"); - // A non-loopback host over http is not secure, so the cookie is - // rejected. - { - cookie_jar jar; - urls::url url("http://example.com/"); - jar.add(url, parse_cookie("s=1; Secure").value()); - BOOST_TEST_EQ(jar.cookie_header(url), ""); - } + jar.add(url, parse_cookie("x=1; Domain=other.com").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); } void @@ -288,36 +154,6 @@ struct cookie_jar_test } } - void - testIPv6() - { - // An IPv6 literal host is keyed without its brackets. - { - cookie_jar jar; - jar.add( - urls::url("http://[::1]/"), parse_cookie("a=1").value()); - BOOST_TEST_EQ( - jar.cookie_header(urls::url("http://[::1]/")), "a=1"); - } - - // The exported jar uses the bracket-free address and re-imports - // to the same key. - { - cookie_jar jar; - jar.add( - urls::url("http://[::1]/"), parse_cookie("a=1").value()); - - const auto s = jar.to_netscape(); - BOOST_TEST(s.find("[") == std::string::npos); - BOOST_TEST(s.find("::1\t") != std::string::npos); - - cookie_jar in; - BOOST_TEST(in.from_netscape(s).has_value()); - BOOST_TEST_EQ( - in.cookie_header(urls::url("http://[::1]/")), "a=1"); - } - } - void testIPHost() { @@ -378,60 +214,106 @@ struct cookie_jar_test } void - testNetscapeValueless() + testIPv6() { - // A value-less cookie exports with an empty value field and must - // re-import without error. - cookie_jar jar; - jar.add( - urls::url("http://example.com/"), parse_cookie("flag=").value()); - - cookie_jar in; - BOOST_TEST(in.from_netscape(jar.to_netscape()).has_value()); - BOOST_TEST_EQ( - in.cookie_header(urls::url("http://example.com/")), "flag="); - } + // An IPv6 literal host is keyed without its brackets. + { + cookie_jar jar; + jar.add( + urls::url("http://[::1]/"), parse_cookie("a=1").value()); + BOOST_TEST_EQ( + jar.cookie_header(urls::url("http://[::1]/")), "a=1"); + } - void - testNetscapeLeadingDot() - { - // A leading-dot domain imported from a file must still match the - // host and its subdomains. - cookie_jar jar; - BOOST_TEST( - jar.from_netscape( - "# Netscape HTTP Cookie File\n\n" - ".example.com\tTRUE\t/\tFALSE\t0\ta\t1\n").has_value()); - BOOST_TEST_EQ( - jar.cookie_header(urls::url("http://www.example.com/")), "a=1"); - BOOST_TEST_EQ( - jar.cookie_header(urls::url("http://example.com/")), "a=1"); + // The exported jar uses the bracket-free address and re-imports + // to the same key. + { + cookie_jar jar; + jar.add( + urls::url("http://[::1]/"), parse_cookie("a=1").value()); - // The leading dot marks tailmatch even when the flag column is FALSE, - // and is stripped so it survives an export round-trip. - cookie_jar dotted; - BOOST_TEST( - dotted.from_netscape( - "# Netscape HTTP Cookie File\n\n" - ".example.com\tFALSE\t/\tFALSE\t0\tb\t2\n").has_value()); - BOOST_TEST_EQ( - dotted.cookie_header(urls::url("http://sub.example.com/")), "b=2"); + const auto s = jar.to_netscape(); + BOOST_TEST(s.find("[") == std::string::npos); + BOOST_TEST(s.find("::1\t") != std::string::npos); - cookie_jar in; - BOOST_TEST(in.from_netscape(dotted.to_netscape()).has_value()); - BOOST_TEST_EQ( - in.cookie_header(urls::url("http://sub.example.com/")), "b=2"); + cookie_jar in; + BOOST_TEST(in.from_netscape(s).has_value()); + BOOST_TEST_EQ( + in.cookie_header(urls::url("http://[::1]/")), "a=1"); + } } + // + // Public suffix + // + void - testPublicSuffixHostOnly() + testPublicSuffix() { - // RFC 6265bis 5.7 step 9: a public-suffix Domain equal to the host is - // accepted as host-only (a bare label is a public suffix either way). + // A registrable domain is accepted. { cookie_jar jar; - urls::url url("http://intranet/"); - jar.add(url, parse_cookie("a=1; Domain=intranet").value()); + urls::url url("https://www.example.com/"); + jar.add(url, parse_cookie("a=1; Domain=example.com").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); + } + + // A bare TLD is rejected, by libpsl and the no-dot fallback alike. + { + cookie_jar jar; + urls::url url("https://example.com/"); + jar.add(url, parse_cookie("a=1; Domain=com").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // RFC 6265bis 5.6.3: a leading dot is ignored — accepted on a domain, + // rejected on a bare TLD. + { + cookie_jar jar; + urls::url url("https://www.example.com/"); + jar.add(url, parse_cookie("a=1; Domain=.example.com").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); + } + { + cookie_jar jar; + urls::url url("https://example.com/"); + jar.add(url, parse_cookie("a=1; Domain=.com").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // A multi-label public suffix is rejected only with libpsl; the + // no-dot fallback can't tell and accepts it. + { + cookie_jar jar; + urls::url url("https://example.co.uk/"); + jar.add(url, parse_cookie("a=1; Domain=co.uk").value()); + if(cookie_jar::public_suffix_supported()) + BOOST_TEST_EQ(jar.cookie_header(url), ""); + else + BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); + } + + // The no-dot fallback special-cases "localhost" as a non-public-suffix + // so a Domain=localhost cookie tailmatches subdomains during local dev. + if(!cookie_jar::public_suffix_supported()) + { + cookie_jar jar; + urls::url url("http://localhost/"); + jar.add(url, parse_cookie("a=1; Domain=localhost").value()); + BOOST_TEST_EQ( + jar.cookie_header(urls::url("http://sub.localhost/")), "a=1"); + } + } + + void + testPublicSuffixHostOnly() + { + // RFC 6265bis 5.7 step 9: a public-suffix Domain equal to the host is + // accepted as host-only (a bare label is a public suffix either way). + { + cookie_jar jar; + urls::url url("http://intranet/"); + jar.add(url, parse_cookie("a=1; Domain=intranet").value()); BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); // host-only: it must not leak to a subdomain. @@ -448,6 +330,137 @@ struct cookie_jar_test } } + // + // Path matching + // + + void + testPathMatch() + { + cookie_jar jar; + jar.add( + urls::url("https://example.com/app"), + parse_cookie("k=1; Path=/app").value()); + + // RFC 6265bis 5.1.4: the cookie path is a prefix ending on a boundary. + BOOST_TEST_EQ(jar.cookie_header(urls::url("https://example.com/app")), "k=1"); + BOOST_TEST_EQ( + jar.cookie_header(urls::url("https://example.com/app/x")), "k=1"); + + // A prefix that is not on a path boundary does not match. + BOOST_TEST_EQ( + jar.cookie_header(urls::url("https://example.com/application")), ""); + + // RFC 6265bis 5.1.4: a no-path request defaults to "/", not matching /app. + BOOST_TEST_EQ( + jar.cookie_header(urls::url("https://example.com")), ""); + + // RFC 6265bis 5.1.4: the defaulted "/" does match a root cookie. + cookie_jar root; + root.add( + urls::url("https://example.com/"), parse_cookie("a=1").value()); + BOOST_TEST_EQ( + root.cookie_header(urls::url("https://example.com")), "a=1"); + } + + void + testDefaultPath() + { + // RFC 6265bis 5.1.4: with no Path attribute, the default path is the + // request's directory (everything up to the last '/'). + cookie_jar jar; + jar.add("https://example.com/app/x", parse_cookie("a=1").value()); + BOOST_TEST_EQ(jar.cookie_header("https://example.com/app/x"), "a=1"); + BOOST_TEST_EQ(jar.cookie_header("https://example.com/app/y"), "a=1"); + BOOST_TEST_EQ(jar.cookie_header("https://example.com/app"), "a=1"); + BOOST_TEST_EQ(jar.cookie_header("https://example.com/"), ""); + } + + // + // Secure context + // + + void + testSecure() + { + cookie_jar jar; + urls::url https("https://example.com/"); + jar.add(https, parse_cookie("s=1; Secure").value()); + + BOOST_TEST_EQ(jar.cookie_header(https), "s=1"); + + urls::url http("http://example.com/"); + BOOST_TEST_EQ(jar.cookie_header(http), ""); + } + + void + testLocalhostSecure() + { + // localhost is a secure context, so Secure cookies are accepted and + // sent over plain http (matches curl/browsers). + { + cookie_jar jar; + urls::url url("http://localhost/"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); + } + + // The same holds for loopback addresses. + { + cookie_jar jar; + urls::url url("http://127.0.0.1/"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); + } + { + cookie_jar jar; + urls::url url("http://127.0.0.255/"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); + } + { + cookie_jar jar; + urls::url url("http://[::1]/"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "s=1"); + } + + // "localhost." is not a secure context: fail closed rather than + // normalize the trailing dot (matches curl's literal check). + { + cookie_jar jar; + urls::url url("http://localhost./"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // A non-loopback host over http is not secure, so the cookie is + // rejected. + { + cookie_jar jar; + urls::url url("http://example.com/"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // An ipvfuture host over http is not a recognized secure context, so + // the cookie is rejected (fail closed). + { + cookie_jar jar; + urls::url url("http://[v1.fe80::a]/"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // A host-less url over http is likewise not secure. + { + cookie_jar jar; + urls::url url("http:/path"); + jar.add(url, parse_cookie("s=1; Secure").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + } + void testLeaveSecureAlone() { @@ -508,16 +521,51 @@ struct cookie_jar_test } } + // + // Expiry and clearing + // + void - testClear() + testExpiry() { - cookie_jar jar; urls::url url("https://example.com/"); - jar.add(url, parse_cookie("a=1").value()); - jar.add(url, parse_cookie("b=2").value()); - jar.clear(); - BOOST_TEST_EQ(jar.cookie_header(url), ""); + // An already-expired cookie with no stored counterpart is dropped. + { + cookie_jar jar; + jar.add(url, parse_cookie("a=1; Max-Age=0").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // RFC 6265bis: a server deletes a stored cookie by re-sending it + // already expired, which erases the existing entry. + { + cookie_jar jar; + jar.add(url, parse_cookie("a=1").value()); + BOOST_TEST_EQ(jar.cookie_header(url), "a=1"); + jar.add(url, parse_cookie("a=2; Max-Age=0").value()); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + } + + void + testLazyExpiry() + { + // epoch 1 is 1970 — already past. from_netscape stores it directly, + // bypassing add()'s expiry check, so a stale entry lands in the jar. + cookie_jar jar; + BOOST_TEST( + jar.from_netscape( + "# Netscape HTTP Cookie File\n\n" + "example.com\tFALSE\t/\tFALSE\t1\ta\t1\n").has_value()); + + // Querying the jar purges the expired entry and returns nothing. + BOOST_TEST_EQ( + jar.cookie_header(urls::url("http://example.com/")), ""); + + // It was erased, not merely filtered: it no longer appears in an + // export. + BOOST_TEST(jar.to_netscape().find("example.com") == std::string::npos); } void @@ -535,41 +583,154 @@ struct cookie_jar_test BOOST_TEST_EQ(jar.cookie_header(url), "p=2"); } + void + testClear() + { + cookie_jar jar; + urls::url url("https://example.com/"); + jar.add(url, parse_cookie("a=1").value()); + jar.add(url, parse_cookie("b=2").value()); + + jar.clear(); + BOOST_TEST_EQ(jar.cookie_header(url), ""); + } + + // + // Netscape import/export + // + void testNetscapeRoundTrip() { cookie_jar jar; urls::url url("https://example.com/path"); - jar.add(url, parse_cookie("id=42; Max-Age=3600").value()); + jar.add(url, parse_cookie("id=42; Max-Age=3600; HttpOnly").value()); jar.add(url, parse_cookie("theme=dark; Max-Age=3600").value()); + // An HttpOnly cookie serializes with the "#HttpOnly_" line prefix. + const auto netscape = jar.to_netscape(); + BOOST_TEST(netscape.find("#HttpOnly_") != std::string::npos); + cookie_jar jar2; - BOOST_TEST(jar2.from_netscape(jar.to_netscape()).has_value()); + BOOST_TEST(jar2.from_netscape(netscape).has_value()); BOOST_TEST_EQ(jar2.cookie_header(url), jar.cookie_header(url)); + // A second export must reproduce the original, proving the HttpOnly + // flag (which the cookie header omits) survived the round-trip. + BOOST_TEST_EQ(jar2.to_netscape(), netscape); + } + + void + testNetscapeValueless() + { + // A value-less cookie exports with an empty value field and must + // re-import without error. + cookie_jar jar; + jar.add( + urls::url("http://example.com/"), parse_cookie("flag=").value()); + + cookie_jar in; + BOOST_TEST(in.from_netscape(jar.to_netscape()).has_value()); + BOOST_TEST_EQ( + in.cookie_header(urls::url("http://example.com/")), "flag="); + } + + void + testNetscapeLeadingDot() + { + // A leading-dot domain imported from a file must still match the + // host and its subdomains. + cookie_jar jar; + BOOST_TEST( + jar.from_netscape( + "# Netscape HTTP Cookie File\n\n" + ".example.com\tTRUE\t/\tFALSE\t0\ta\t1\n").has_value()); + BOOST_TEST_EQ( + jar.cookie_header(urls::url("http://www.example.com/")), "a=1"); + BOOST_TEST_EQ( + jar.cookie_header(urls::url("http://example.com/")), "a=1"); + + // The leading dot marks tailmatch even when the flag column is FALSE, + // and is stripped so it survives an export round-trip. + cookie_jar dotted; + BOOST_TEST( + dotted.from_netscape( + "# Netscape HTTP Cookie File\n\n" + ".example.com\tFALSE\t/\tFALSE\t0\tb\t2\n").has_value()); + BOOST_TEST_EQ( + dotted.cookie_header(urls::url("http://sub.example.com/")), "b=2"); + + cookie_jar in; + BOOST_TEST(in.from_netscape(dotted.to_netscape()).has_value()); + BOOST_TEST_EQ( + in.cookie_header(urls::url("http://sub.example.com/")), "b=2"); + } + + void + testNetscapeCRLF() + { + // CRLF line endings are tolerated: the trailing '\r' is stripped so + // the value field parses as "1", not "1\r". + cookie_jar jar; + BOOST_TEST( + jar.from_netscape( + "# Netscape HTTP Cookie File\r\n\r\n" + "example.com\tFALSE\t/\tFALSE\t0\ta\t1\r\n").has_value()); + BOOST_TEST_EQ( + jar.cookie_header(urls::url("http://example.com/")), "a=1"); + } + + void + testNetscapeMalformed() + { + // A line that does not match the fixed tab-delimited shape fails to + // parse and the error is propagated out of from_netscape. + cookie_jar jar; + BOOST_TEST( + jar.from_netscape( + "# Netscape HTTP Cookie File\n\n" + "example.com\tTRUE\t/\n").has_error()); } void run() { + // Adding, replacing, and header ordering testAddAndHeader(); - testSecure(); + testReplace(); + testOrdering(); + + // Domain matching testDomainMismatch(); + testTrailingDot(); + testIPHost(); + testIPv6(); + + // Public suffix testPublicSuffix(); testPublicSuffixHostOnly(); - testReplace(); + + // Path matching testPathMatch(); - testOrdering(); + testDefaultPath(); + + // Secure context + testSecure(); testLocalhostSecure(); - testTrailingDot(); - testIPv6(); - testIPHost(); - testNetscapeValueless(); - testNetscapeLeadingDot(); testLeaveSecureAlone(); - testClear(); + + // Expiry and clearing + testExpiry(); + testLazyExpiry(); testClearSessionCookies(); + testClear(); + + // Netscape import/export testNetscapeRoundTrip(); + testNetscapeValueless(); + testNetscapeLeadingDot(); + testNetscapeCRLF(); + testNetscapeMalformed(); } };