diff --git a/README.md b/README.md index 2ce5316..02521c2 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,14 @@ subsequent requests automatically: (Result.Error e) (IO.errorln &e))) ``` -The jar handles domain matching (RFC 6265 suffix rules), path matching, -Secure flag enforcement, and expiry. Cookies are deduplicated by -name+domain+path. During redirects, cookies from every hop are stored and -re-applied for each new URL. +The jar follows RFC 6265 §5.3 and §5.4. A cookie that arrives with no +`Domain` attribute is host-only: it goes back to the host that set it and to +no subdomain. A `Domain` attribute the responding host does not domain-match +is rejected outright, and so is a single-label one such as `Domain=com`. On +top of that the jar enforces path matching, the `Secure` flag, and expiry; +cookies are deduplicated by name+domain+path and serialized longest path +first. During redirects, cookies from every hop are stored and re-applied for +each new URL. ### Multipart uploads @@ -207,9 +211,9 @@ given. | Function | Purpose | |----------|---------| | `CookieJar.create` | Create an empty jar | -| `CookieJar.store! jar cookie` | Store a cookie, replacing duplicates by name+domain+path | -| `CookieJar.store-response! jar response url` | Store cookies from a response, defaulting domain from URL | -| `CookieJar.matching jar url` | Return cookies matching the URL by domain, path, security, and expiry | +| `CookieJar.store! jar cookie` | Store a cookie as a domain cookie, replacing duplicates by name+domain+path | +| `CookieJar.store-response! jar response url` | Store a response's cookies, applying RFC 6265 §5.3's origin checks | +| `CookieJar.matching jar url` | Return cookies matching the URL by domain, path, security, and expiry, longest path first | | `CookieJar.cookie-header jar url` | Build a `Cookie` header value, or `Nothing` if no cookies match | | `CookieJar.apply-to-headers jar url headers` | Add a `Cookie` header to the headers map | | `CookieJar.size jar` | Number of stored cookies | diff --git a/docs/Client.html b/docs/Client.html index 2674529..eb8ba00 100644 --- a/docs/Client.html +++ b/docs/Client.html @@ -32,6 +32,11 @@ CookieJar +
  • + + JarCookie + +
  • Multipart diff --git a/docs/Connection.html b/docs/Connection.html index 2d75e74..29cd8eb 100644 --- a/docs/Connection.html +++ b/docs/Connection.html @@ -32,6 +32,11 @@ CookieJar
  • +
  • + + JarCookie + +
  • Multipart diff --git a/docs/CookieJar.html b/docs/CookieJar.html index 1f019d4..54e9b95 100644 --- a/docs/CookieJar.html +++ b/docs/CookieJar.html @@ -32,6 +32,11 @@ CookieJar
  • +
  • + + JarCookie + +
  • Multipart @@ -117,7 +122,7 @@

    instantiate

    - (Fn [(Ref CookieJar a)] (Ref (Array Cookie) a)) + (Fn [(Ref CookieJar a)] (Ref (Array JarCookie) a))

    @@ -211,7 +216,7 @@

    instantiate

    - (Fn [(Array Cookie)] CookieJar) + (Fn [(Array JarCookie)] CookieJar)

    @@ -238,7 +243,12 @@

    returns cookies matching the given URL by domain, path, -security, and expiry.

    +security, and expiry, longest path first (RFC 6265 §5.4 step 2). Cookies of +equal path length keep their insertion order; the creation time §5.4 asks for +as the second sort key is not recorded.

    +

    A cookie stored without a Domain attribute is host-only and matches its +origin host alone; one stored with a Domain attribute also matches that +domain’s subdomains, unless the request host is an IP literal.

    @@ -272,7 +282,7 @@

    instantiate

    - (Fn [CookieJar, (Array Cookie)] CookieJar) + (Fn [CookieJar, (Array JarCookie)] CookieJar)

    @@ -292,7 +302,7 @@

    instantiate

    - (Fn [(Ref CookieJar a), (Array Cookie)] ()) + (Fn [(Ref CookieJar a), (Array JarCookie)] ())

    @@ -332,14 +342,16 @@

    defn

    - (Fn [(Ref CookieJar a), (Ref Cookie a)] ()) + (Fn [(Ref CookieJar a), (Ref Cookie b)] ())

                             (store! jar c)
                         

    -

    stores a cookie, replacing any with the same name, domain, -and path.

    +

    stores a cookie as a domain cookie, replacing any with the same +name, domain, and path. The cookie is trusted as given: there is no request +origin to check it against, so prefer store-response! for anything that came +off the wire.

    @@ -359,8 +371,14 @@

    (store-response! jar resp url)

    -

    stores cookies from a response. The URL provides the -default domain for cookies without a Domain attribute.

    +

    stores cookies from a response, applying RFC 6265 §5.3.

    +

    A cookie with no Domain attribute becomes host-only: it is replayed to the +URL’s host and to no other. A Domain attribute the URL’s host does not +domain-match is rejected outright. So is one with no embedded dot — the +pre-public-suffix-list approximation of §5.3 step 5, which lets through +registrable multi-label suffixes such as co.uk — unless it is identical to +the host, in which case the cookie is kept host-only, so Domain=localhost +from http://localhost/ still works.

    @@ -394,7 +412,7 @@

    instantiate

    - (Fn [CookieJar, (Ref (Fn [(Array Cookie)] (Array Cookie) a) b)] CookieJar) + (Fn [CookieJar, (Ref (Fn [(Array JarCookie)] (Array JarCookie) a) b)] CookieJar)

    diff --git a/docs/JarCookie.html b/docs/JarCookie.html new file mode 100644 index 0000000..4856954 --- /dev/null +++ b/docs/JarCookie.html @@ -0,0 +1,321 @@ + + + + + + + + + +
    + +
    +

    + JarCookie +

    +
    +

    is a stored cookie together with RFC 6265 §5.3’s +host-only-flag, which is set when the cookie arrived without a Domain +attribute and may therefore only go back to the host that set it.

    + +
    +
    + + + +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a)] (Ref Cookie a)) +

    + + + +

    +

    gets the cookie property of a JarCookie.

    + +

    +
    +
    + +

    + copy +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a)] JarCookie) +

    + + + +

    +

    copies a JarCookie.

    + +

    +
    +
    + +

    + delete +

    +
    +
    + instantiate +
    +

    + (Fn [JarCookie] ()) +

    + + + +

    +

    deletes a JarCookie. Should usually not be called manually.

    + +

    +
    +
    + +

    + host-only +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a)] (Ref Bool a)) +

    + + + +

    +

    gets the host-only property of a JarCookie.

    + +

    +
    +
    + +

    + init +

    +
    +
    + instantiate +
    +

    + (Fn [Cookie, Bool] JarCookie) +

    + + + +

    +

    creates a JarCookie.

    + +

    +
    +
    + +

    + prn +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a)] String) +

    + + + +

    +

    converts a JarCookie to a string.

    + +

    +
    +
    + + + +
    + instantiate +
    +

    + (Fn [JarCookie, Cookie] JarCookie) +

    + + + +

    +

    sets the cookie property of a JarCookie.

    + +

    +
    +
    + +

    + set-cookie! +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a), Cookie] ()) +

    + + + +

    +

    sets the cookie property of a JarCookie in place.

    + +

    +
    +
    + +

    + set-host-only +

    +
    +
    + instantiate +
    +

    + (Fn [JarCookie, Bool] JarCookie) +

    + + + +

    +

    sets the host-only property of a JarCookie.

    + +

    +
    +
    + +

    + set-host-only! +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a), Bool] ()) +

    + + + +

    +

    sets the host-only property of a JarCookie in place.

    + +

    +
    +
    + +

    + str +

    +
    +
    + instantiate +
    +

    + (Fn [(Ref JarCookie a)] String) +

    + + + +

    +

    converts a JarCookie to a string.

    + +

    +
    +
    + + + +
    + instantiate +
    +

    + (Fn [JarCookie, (Ref (Fn [Cookie] Cookie a) b)] JarCookie) +

    + + + +

    +

    updates the cookie property of a JarCookie using a function f.

    + +

    +
    +
    + +

    + update-host-only +

    +
    +
    + instantiate +
    +

    + (Fn [JarCookie, (Ref (Fn [Bool] Bool a) b)] JarCookie) +

    + + + +

    +

    updates the host-only property of a JarCookie using a function f.

    + +

    +
    +
    +
    + + diff --git a/docs/Multipart.html b/docs/Multipart.html index 6eb2261..5bbde13 100644 --- a/docs/Multipart.html +++ b/docs/Multipart.html @@ -32,6 +32,11 @@ CookieJar

  • +
  • + + JarCookie + +
  • Multipart diff --git a/docs/http-client_index.html b/docs/http-client_index.html index 4da4510..ff21d56 100644 --- a/docs/http-client_index.html +++ b/docs/http-client_index.html @@ -28,6 +28,11 @@ CookieJar
  • +
  • + + JarCookie + +
  • Multipart diff --git a/docs/index.html b/docs/index.html index 4da4510..ff21d56 100644 --- a/docs/index.html +++ b/docs/index.html @@ -28,6 +28,11 @@ CookieJar
  • +
  • + + JarCookie + +
  • Multipart diff --git a/gendocs.carp b/gendocs.carp index 55c6daf..786a7ce 100644 --- a/gendocs.carp +++ b/gendocs.carp @@ -37,5 +37,5 @@ transparently over plain TCP or TLS-encrypted streams. Requires OpenSSL for HTTPS support (via the `tls` library). Plain HTTP works without OpenSSL.") -(save-docs Client Connection CookieJar Multipart) +(save-docs Client Connection CookieJar JarCookie Multipart) (quit) diff --git a/src/cookie-jar.carp b/src/cookie-jar.carp index 90ab9da..5e2096a 100644 --- a/src/cookie-jar.carp +++ b/src/cookie-jar.carp @@ -2,6 +2,12 @@ ; Stores cookies from Set-Cookie response headers and replays matching ; cookies on subsequent requests by domain, path, and expiry. +(doc JarCookie "is a stored cookie together with RFC 6265 §5.3’s +host-only-flag, which is set when the cookie arrived without a `Domain` +attribute and may therefore only go back to the host that set it.") +(deftype JarCookie [cookie Cookie + host-only Bool]) + (doc CookieJar "stores cookies and replays them on matching requests. Create a jar with `CookieJar.create`, then pass it to `Client.get-with-jar` @@ -17,19 +23,52 @@ and similar functions: (Result.Success r) (println* (Response.body &r)) (Result.Error e) (IO.errorln &e))) ```") -(deftype CookieJar [cookies (Array Cookie)]) +(deftype CookieJar [cookies (Array JarCookie)]) (defmodule CookieJar (doc create "creates an empty cookie jar.") (defn create [] (init [])) + (hidden canonical-host) + (private canonical-host) + (defn canonical-host [s] (String.ascii-to-lower &(String.trim s))) + + (hidden strip-leading-dot) + (private strip-leading-dot) + (defn strip-leading-dot [d] (if (byte-starts-with? d ".") (drop-bytes d 1) @d)) + + (hidden ip-literal?) + (private ip-literal?) + (defn ip-literal? [host] + (and (not (String.empty? host)) + (or (String.contains-string? host ":") + (Array.all? + &(fn [b] (let [v @b] (or (= v 46b) (and (> v 47b) (< v 58b))))) + &(String.to-bytes host))))) + (hidden domain-matches?) (private domain-matches?) (defn domain-matches? [cookie-domain host] - (let [cd (String.ascii-to-lower &(String.trim cookie-domain)) - h (String.ascii-to-lower host) - norm (if (byte-starts-with? &cd ".") (drop-bytes &cd 1) @&cd)] - (or (= &norm &h) (byte-ends-with? &h &(fmt ".%s" &norm))))) + (let [norm (strip-leading-dot &(canonical-host cookie-domain)) + h (canonical-host host)] + (or (= &norm &h) + (and (not (ip-literal? &h)) (byte-ends-with? &h &(fmt ".%s" &norm)))))) + + ; a dot at neither edge, so `.com` and `com.` stay single-label + (hidden embedded-dot?) + (private embedded-dot?) + (defn embedded-dot? [d] + (let [n (String.length d)] + (and (> n 2) + (String.contains-string? &(String.byte-slice d 1 (dec n)) ".")))) + + (hidden host-matches?) + (private host-matches?) + (defn host-matches? [e host] + (let [d (Maybe.from @(Cookie.domain (JarCookie.cookie e)) @"")] + (if @(JarCookie.host-only e) + (= &(canonical-host &d) host) + (domain-matches? &d host)))) (hidden path-matches?) (private path-matches?) @@ -39,63 +78,116 @@ and similar functions: (byte-starts-with? request-path cookie-path) (byte-starts-with? request-path &(fmt "%s/" cookie-path))))) - (doc store! "stores a cookie, replacing any with the same name, domain, -and path.") - (defn store! [jar c] - (let [filtered (Array.reduce + (hidden longest-path-first) + (private longest-path-first) + (defn longest-path-first [cs] + (let-do [n (Array.length cs) + lens (Array.copy-map &(fn [c] (String.length (Cookie.path c))) cs) + taken (Array.replicate n &false) + out (the (Array Cookie) [])] + (for [-k 0 n] + (let-do [best -1] + (for [i 0 n] + (when (and (not @(Array.unsafe-nth &taken i)) + (or (= best -1) + (> @(Array.unsafe-nth &lens i) + @(Array.unsafe-nth &lens best)))) + (set! best i))) + (Array.aset! &taken best true) + (set! out (Array.push-back out @(Array.unsafe-nth cs best))))) + out)) + + (hidden store-entry!) + (private store-entry!) + (defn store-entry! [jar e] + (let [c (JarCookie.cookie e) + filtered (Array.reduce &(fn [acc existing] - (if (and - (= (Cookie.name existing) (Cookie.name c)) - (and - (= (Cookie.domain existing) (Cookie.domain c)) - (= (Cookie.path existing) (Cookie.path c)))) - acc - (Array.push-back acc @existing))) - (the (Array Cookie) []) + (let [ec (JarCookie.cookie existing)] + (if (and (= (Cookie.name ec) (Cookie.name c)) + (and (= (Cookie.domain ec) (Cookie.domain c)) + (= (Cookie.path ec) (Cookie.path c)))) + acc + (Array.push-back acc @existing)))) + (the (Array JarCookie) []) (cookies jar)) - new-arr (Array.push-back filtered @c)] + new-arr (Array.push-back filtered @e)] (set-cookies! jar new-arr))) - (doc store-response! "stores cookies from a response. The URL provides the -default domain for cookies without a Domain attribute.") + (hidden store-host-only!) + (private store-host-only!) + (defn store-host-only! [jar c host] + (store-entry! jar + &(JarCookie.init + (Cookie.set-domain @c (Maybe.Just @host)) + true))) + + (doc store! "stores a cookie as a domain cookie, replacing any with the same +name, domain, and path. The cookie is trusted as given: there is no request +origin to check it against, so prefer `store-response!` for anything that came +off the wire.") + (defn store! [jar c] (store-entry! jar &(JarCookie.init @c false))) + + (doc store-response! "stores cookies from a response, applying RFC 6265 §5.3. + +A cookie with no `Domain` attribute becomes host-only: it is replayed to the +URL’s host and to no other. A `Domain` attribute the URL’s host does not +domain-match is rejected outright. So is one with no embedded dot — the +pre-public-suffix-list approximation of §5.3 step 5, which lets through +registrable multi-label suffixes such as `co.uk` — unless it is identical to +the host, in which case the cookie is kept host-only, so `Domain=localhost` +from `http://localhost/` still works.") (defn store-response! [jar resp url] (match (URI.parse url) (Result.Error _) () (Result.Success uri) - (let [host (Maybe.from @(URI.host &uri) @"")] + (let [host (canonical-host &(Maybe.from @(URI.host &uri) @""))] (for [i 0 (Array.length (Response.cookies resp))] - (let [raw @(Array.unsafe-nth (Response.cookies resp) i) - c (if (Maybe.nothing? (Cookie.domain &raw)) - (Cookie.set-domain raw (Maybe.Just @&host)) - raw)] - (store! jar &c)))))) + (let [raw (Array.unsafe-nth (Response.cookies resp) i)] + (match @(Cookie.domain raw) + (Maybe.Nothing) (store-host-only! jar raw &host) + (Maybe.Just d) + (let [norm (strip-leading-dot &(canonical-host &d))] + (cond + (not (embedded-dot? &norm)) + (when (= &norm &host) (store-host-only! jar raw &host)) + (domain-matches? &norm &host) + (store-entry! jar &(JarCookie.init @raw false)) + ())))))))) (doc matching "returns cookies matching the given URL by domain, path, -security, and expiry.") +security, and expiry, longest path first (RFC 6265 §5.4 step 2). Cookies of +equal path length keep their insertion order; the creation time §5.4 asks for +as the second sort key is not recorded. + +A cookie stored without a `Domain` attribute is host-only and matches its +origin host alone; one stored with a `Domain` attribute also matches that +domain’s subdomains, unless the request host is an IP literal.") (defn matching [jar url] (match (URI.parse url) (Result.Error _) [] (Result.Success uri) - (let [host (Maybe.from @(URI.host &uri) @"") + (let [host (canonical-host &(Maybe.from @(URI.host &uri) @"")) path (let [p (Maybe.from @(URI.path &uri) @"")] (if (byte-starts-with? &p "/") p (fmt "/%s" &p))) scheme (Maybe.from @(URI.scheme &uri) @"http") is-secure (= &scheme "https")] - (Array.reduce - &(fn [acc c] - (let [d (Maybe.from @(Cookie.domain c) @"")] - (cond - (Cookie.expired? c) acc - (and @(Cookie.secure c) (not is-secure)) acc - (Maybe.nothing? (Cookie.domain c)) acc - (not (domain-matches? &d &host)) acc - (not (path-matches? (Cookie.path c) &path)) acc - (Array.push-back acc @c)))) - (the (Array Cookie) []) - (cookies jar))))) + (longest-path-first + &(Array.reduce + &(fn [acc e] + (let [c (JarCookie.cookie e)] + (cond + (Cookie.expired? c) acc + (and @(Cookie.secure c) (not is-secure)) acc + (Maybe.nothing? (Cookie.domain c)) acc + (not (host-matches? e &host)) acc + (not (path-matches? (Cookie.path c) &path)) acc + (Array.push-back acc @c)))) + (the (Array Cookie) []) + (cookies jar)))))) (doc cookie-header "builds a Cookie header value for the URL, or Nothing -if no cookies match.") +if no cookies match. Cookies are serialized longest path first.") (defn cookie-header [jar url] (let [matched (matching jar url)] (if (Array.empty? &matched) @@ -121,4 +213,4 @@ headers map. Returns headers unchanged if no cookies match.") (defn size [jar] (Array.length (cookies jar))) (doc clear! "removes all cookies.") - (defn clear! [jar] (set-cookies! jar (the (Array Cookie) [])))) + (defn clear! [jar] (set-cookies! jar (the (Array JarCookie) [])))) diff --git a/test/cookie-jar.carp b/test/cookie-jar.carp index 78658d8..7c1a167 100644 --- a/test/cookie-jar.carp +++ b/test/cookie-jar.carp @@ -14,6 +14,27 @@ (SameSite.Lax))) (Array.length &(CookieJar.matching &jar "http://example.com/p")))) +(defn jar-from [url domain] + (let-do [jar (CookieJar.create) + resp (Response.init 200 + @"OK" + @"HTTP/1.1" + [(Cookie.init @"sid" + @"abc" + @"/" + (Maybe.Nothing) + domain + false + false + (SameSite.Lax))] + (the (Map String (Array String)) {}) + @"")] + (CookieJar.store-response! &jar &resp url) + jar)) + +(defn sent-to [url domain target] + (Array.length &(CookieJar.matching &(jar-from url domain) target))) + (defn jar-with-path [path] (let-do [jar (CookieJar.create)] (CookieJar.store! &jar @@ -27,6 +48,35 @@ (SameSite.Lax))) (Array.length &(CookieJar.matching &jar "http://example.com/p")))) +(defn redirect-jar [] + (let-do [jar (CookieJar.create) + resp (Response.init 200 + @"OK" + @"HTTP/1.1" + [(Cookie.init @"sid" + @"abc" + @"/" + (Maybe.Nothing) + (Maybe.Nothing) + false + false + (SameSite.Lax))] + (the (Map String (Array String)) {}) + @"")] + (CookieJar.store-response! &jar &resp "http://a.example.com/one") + (CookieJar.store-response! &jar &resp "http://b.example.com/two") + jar)) + +(defn at-path [name value path] + (Cookie.init @name + @value + @path + (Maybe.Nothing) + (Maybe.Just @"example.com") + false + false + (SameSite.Lax))) + (defn cookie-jar-tests [state] (let-do [test @state] (set! test @@ -451,44 +501,25 @@ (set! test (assert-equal &test 1 - (let-do [jar (CookieJar.create) - resp (Response.init 200 - @"OK" - @"HTTP/1.1" - [(Cookie.init @"sid" - @"abc" - @"/" - (Maybe.Nothing) - (Maybe.Just @"other.com") - false - false - (SameSite.Lax))] - (the (Map String (Array String)) {}) - @"")] - (CookieJar.store-response! &jar &resp "https://example.com/login") - (Array.length &(CookieJar.matching &jar "https://other.com/"))) - "store-response! preserves explicit Domain attribute")) + (sent-to "https://sub.example.com/login" + (Maybe.Just @"example.com") + "https://example.com/") + "store-response! keeps a Domain the origin domain-matches")) (set! test (assert-equal &test 0 - (let-do [jar (CookieJar.create) - resp (Response.init 200 - @"OK" - @"HTTP/1.1" - [(Cookie.init @"sid" - @"abc" - @"/" - (Maybe.Nothing) - (Maybe.Just @"other.com") - false - false - (SameSite.Lax))] - (the (Map String (Array String)) {}) - @"")] - (CookieJar.store-response! &jar &resp "https://example.com/login") - (Array.length &(CookieJar.matching &jar "https://example.com/"))) - "explicit Domain cookie does not match request host")) + (CookieJar.size + &(jar-from "https://evil.com/" (Maybe.Just @"example.com"))) + "a Domain the origin does not domain-match is rejected outright")) + + (set! test + (assert-equal &test + 0 + (sent-to "https://evil.com/" + (Maybe.Just @"example.com") + "https://example.com/") + "a rejected cross-origin Domain is never replayed")) (set! test (assert-equal &test @@ -553,4 +584,143 @@ (jar-with-path &(String.concat &[@"/" (continuation 3) @"/"])) "a directory Path that is not valid UTF-8 does not match")) + (set! test + (assert-equal &test + 0 + (sent-to "https://example.com/login" + (Maybe.Nothing) + "https://sub.example.com/") + "a cookie stored with no Domain is host-only")) + + (set! test + (assert-equal &test + 1 + (sent-to "https://example.com/login" + (Maybe.Just @"example.com") + "https://sub.example.com/") + "an explicit Domain equal to the host still reaches subdomains")) + + (set! test + (assert-equal &test + 0 + (CookieJar.size + &(jar-from "https://example.com/" (Maybe.Just @"com"))) + "a single-label Domain is rejected")) + + (set! test + (assert-equal &test + 0 + (CookieJar.size + &(jar-from "https://example.com/" (Maybe.Just @".com"))) + "a leading dot does not rescue a single-label Domain")) + + (set! test + (assert-equal &test + 0 + (CookieJar.size + &(jar-from "https://example.com/" (Maybe.Just @"..com"))) + "a doubled leading dot does not rescue a single-label Domain")) + + (set! test + (assert-equal &test + 1 + (sent-to "http://localhost/" + (Maybe.Just @"localhost") + "http://localhost/") + "a single-label Domain identical to the host is kept")) + + (set! test + (assert-equal &test + 0 + (sent-to "http://localhost/" + (Maybe.Just @"localhost") + "http://sub.localhost/") + "a single-label Domain identical to the host is host-only")) + + (set! test + (assert-equal &test + 1 + (sent-to "https://example.com/" + (Maybe.Just @".example.com") + "https://example.com/") + "a leading dot in the Domain attribute is ignored")) + + (set! test + (assert-equal &test + 1 + (sent-to "https://example.com/" + (Maybe.Just @".example.com") + "https://sub.example.com/") + "a leading-dot Domain reaches subdomains")) + + (set! test + (assert-equal &test + 1 + (sent-to "http://EXAMPLE.com/" (Maybe.Nothing) "http://example.com/") + "a host-only cookie ignores the case of the origin host")) + + (set! test + (assert-equal &test + 1 + (sent-to "http://example.com/" (Maybe.Nothing) "http://EXAMPLE.COM/") + "a host-only cookie ignores the case of the request host")) + + (set! test + (assert-equal &test + 1 + (sent-to "http://127.0.0.1/" (Maybe.Nothing) "http://127.0.0.1/") + "an IP-literal host gets its own cookie back")) + + (set! test + (assert-equal &test + 0 + (sent-to "http://127.0.0.1/" (Maybe.Nothing) "http://1.127.0.0.1/") + "an IP-literal host matches exactly, not by suffix")) + + (set! test + (assert-equal &test + 0 + (CookieJar.size + &(jar-from "http://127.0.0.1/" (Maybe.Just @"0.0.1"))) + "an IP-literal origin does not domain-match a suffix Domain")) + + (set! test + (assert-equal &test + 2 + (CookieJar.size &(redirect-jar)) + "a redirect to another origin stores a second host-only cookie")) + + (set! test + (assert-equal &test + 1 + (Array.length + &(CookieJar.matching &(redirect-jar) "http://a.example.com/")) + "each hop of a redirect chain gets only the cookie it set")) + + (set! test + (assert-equal &test + 0 + (Array.length + &(CookieJar.matching &(redirect-jar) "http://example.com/")) + "a redirect chain's host-only cookies do not reach the apex")) + + (set! test + (assert-equal &test + &(Maybe.Just @"deep=3; mid=2; root=1") + &(let-do [jar (CookieJar.create)] + (CookieJar.store! &jar &(at-path "root" "1" "/")) + (CookieJar.store! &jar &(at-path "deep" "3" "/a/b/c")) + (CookieJar.store! &jar &(at-path "mid" "2" "/a")) + (CookieJar.cookie-header &jar "https://example.com/a/b/c/d")) + "cookie-header serializes longer paths first")) + + (set! test + (assert-equal &test + &(Maybe.Just @"y=2; x=1") + &(let-do [jar (CookieJar.create)] + (CookieJar.store! &jar &(at-path "y" "2" "/ab")) + (CookieJar.store! &jar &(at-path "x" "1" "/ab")) + (CookieJar.cookie-header &jar "https://example.com/ab/z")) + "cookies of equal path length keep their insertion order")) + test))