OCaml HTTP cookie handling library with support for Eio-based storage jars

rfc complaince

+416 -26
+22 -12
lib/core/cookeio.ml
··· 40 40 secure : bool; 41 41 http_only : bool; 42 42 partitioned : bool; 43 + host_only : bool; 43 44 expires : Expiration.t option; 44 45 max_age : Ptime.Span.t option; 45 46 same_site : SameSite.t option; ··· 67 68 let secure cookie = cookie.secure 68 69 let http_only cookie = cookie.http_only 69 70 let partitioned cookie = cookie.partitioned 71 + let host_only cookie = cookie.host_only 70 72 let expires cookie = cookie.expires 71 73 let max_age cookie = cookie.max_age 72 74 let same_site cookie = cookie.same_site ··· 74 76 let last_access cookie = cookie.last_access 75 77 76 78 let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false) 77 - ?expires ?max_age ?same_site ?(partitioned = false) ~creation_time 78 - ~last_access () = 79 + ?expires ?max_age ?same_site ?(partitioned = false) ?(host_only = false) 80 + ~creation_time ~last_access () = 79 81 { 80 82 domain; 81 83 path; ··· 84 86 secure; 85 87 http_only; 86 88 partitioned; 89 + host_only; 87 90 expires; 88 91 max_age; 89 92 same_site; ··· 304 307 in 305 308 samesite_valid && partitioned_valid 306 309 307 - (** Build final cookie from name/value and accumulated attributes *) 310 + (** Build final cookie from name/value and accumulated attributes. 311 + Per RFC 6265 Section 5.3: 312 + - If Domain attribute is present, host_only = false, domain = attribute value 313 + - If Domain attribute is absent, host_only = true, domain = request host *) 308 314 let build_cookie ~request_domain ~request_path ~name ~value attrs ~now = 309 - let domain = 310 - normalize_domain (Option.value attrs.domain ~default:request_domain) 315 + let host_only, domain = 316 + match attrs.domain with 317 + | Some d -> (false, normalize_domain d) 318 + | None -> (true, request_domain) 311 319 in 312 320 let path = Option.value attrs.path ~default:request_path in 313 321 make ~domain ~path ~name ~value ~secure:attrs.secure 314 322 ~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age 315 - ?same_site:attrs.same_site ~partitioned:attrs.partitioned ~creation_time:now 316 - ~last_access:now () 323 + ?same_site:attrs.same_site ~partitioned:attrs.partitioned ~host_only 324 + ~creation_time:now ~last_access:now () 317 325 318 326 (** {1 Pretty Printing} *) 319 327 320 328 let pp ppf cookie = 321 329 Format.fprintf ppf 322 330 "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \ 323 - http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a \ 324 - }@]" 331 + http_only=%b;@ partitioned=%b;@ host_only=%b;@ expires=%a;@ max_age=%a;@ \ 332 + same_site=%a }@]" 325 333 (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie) 326 - (http_only cookie) (partitioned cookie) 334 + (http_only cookie) (partitioned cookie) (host_only cookie) 327 335 (Format.pp_print_option Expiration.pp) 328 336 (expires cookie) 329 337 (Format.pp_print_option Ptime.Span.pp) ··· 410 418 |> String.trim 411 419 in 412 420 let current_time = now () in 413 - (* Create cookie with defaults from Cookie header context *) 421 + (* Create cookie with defaults from Cookie header context. 422 + Cookies from Cookie headers have host_only=true since we don't 423 + know if they originally had a Domain attribute. *) 414 424 let cookie = 415 425 make ~domain ~path ~name:cookie_name ~value:cookie_value 416 - ~secure:false ~http_only:false ~partitioned:false 426 + ~secure:false ~http_only:false ~partitioned:false ~host_only:true 417 427 ~creation_time:current_time ~last_access:current_time () 418 428 in 419 429 Ok cookie)
+20
lib/core/cookeio.mli
··· 115 115 privacy-preserving third-party cookie functionality. Partitioned cookies 116 116 must always be Secure. *) 117 117 118 + val host_only : t -> bool 119 + (** Check if cookie has the host-only flag set. 120 + 121 + Per RFC 6265 Section 5.3: 122 + - If the Set-Cookie header included a Domain attribute, host_only is false 123 + and the cookie matches the domain and all subdomains. 124 + - If no Domain attribute was present, host_only is true and the cookie 125 + only matches the exact request host. 126 + 127 + Example: 128 + - Cookie set on "example.com" with Domain=example.com: host_only=false, 129 + matches example.com and sub.example.com 130 + - Cookie set on "example.com" without Domain attribute: host_only=true, 131 + matches only example.com, not sub.example.com *) 132 + 118 133 val expires : t -> Expiration.t option 119 134 (** Get the expiration attribute if set. 120 135 ··· 152 167 ?max_age:Ptime.Span.t -> 153 168 ?same_site:SameSite.t -> 154 169 ?partitioned:bool -> 170 + ?host_only:bool -> 155 171 creation_time:Ptime.t -> 156 172 last_access:Ptime.t -> 157 173 unit -> 158 174 t 159 175 (** Create a new cookie with the given attributes. 176 + 177 + @param host_only If true, the cookie only matches the exact domain (no 178 + subdomains). Defaults to false. Per RFC 6265, this should be true when no 179 + Domain attribute was present in the Set-Cookie header. 160 180 161 181 Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid 162 182 combinations will result in validation errors. *)
+26 -14
lib/jar/cookeio_jar.ml
··· 34 34 String.sub domain 1 (String.length domain - 1) 35 35 | _ -> domain 36 36 37 - let domain_matches cookie_domain request_domain = 38 - (* Cookie domains are stored without leading dots per RFC 6265. 39 - A cookie with domain "example.com" should match both "example.com" (exact) 40 - and "sub.example.com" (subdomain). *) 37 + let domain_matches ~host_only cookie_domain request_domain = 38 + (* RFC 6265 Section 5.4: Domain matching for Cookie header. 39 + Cookie domains are stored without leading dots per RFC 6265. *) 41 40 request_domain = cookie_domain 42 - || String.ends_with ~suffix:("." ^ cookie_domain) request_domain 41 + || (not host_only 42 + && String.ends_with ~suffix:("." ^ cookie_domain) request_domain) 43 43 44 44 let path_matches cookie_path request_path = 45 - (* Cookie path /foo matches /foo, /foo/, /foo/bar *) 46 - String.starts_with ~prefix:cookie_path request_path 45 + (* RFC 6265 Section 5.1.4: A request-path path-matches a cookie-path if: 46 + 1. The cookie-path and the request-path are identical, OR 47 + 2. The cookie-path is a prefix of request-path AND cookie-path ends with "/", OR 48 + 3. The cookie-path is a prefix of request-path AND the first char of 49 + request-path not in cookie-path is "/" *) 50 + if cookie_path = request_path then true 51 + else if String.starts_with ~prefix:cookie_path request_path then 52 + let cookie_len = String.length cookie_path in 53 + String.ends_with ~suffix:"/" cookie_path 54 + || (String.length request_path > cookie_len && request_path.[cookie_len] = '/') 55 + else false 47 56 48 57 (** {1 HTTP Date Parsing} *) 49 58 let is_expired cookie clock = ··· 123 132 ~http_only:(Cookeio.http_only cookie) ~expires:(`DateTime past_expiry) 124 133 ~max_age:(Ptime.Span.of_int_s 0) ?same_site:(Cookeio.same_site cookie) 125 134 ~partitioned:(Cookeio.partitioned cookie) 135 + ~host_only:(Cookeio.host_only cookie) 126 136 ~creation_time:now ~last_access:now () 127 137 128 138 let remove jar ~clock cookie = ··· 184 194 (fun cookie -> 185 195 Cookeio.value cookie <> "" 186 196 (* Exclude removal cookies *) 187 - && domain_matches (Cookeio.domain cookie) request_domain 197 + && domain_matches ~host_only:(Cookeio.host_only cookie) 198 + (Cookeio.domain cookie) request_domain 188 199 && path_matches (Cookeio.path cookie) request_path 189 200 && ((not (Cookeio.secure cookie)) || is_secure)) 190 201 unique_cookies ··· 204 215 ?expires:(Cookeio.expires c) ?max_age:(Cookeio.max_age c) 205 216 ?same_site:(Cookeio.same_site c) 206 217 ~partitioned:(Cookeio.partitioned c) 218 + ~host_only:(Cookeio.host_only c) 207 219 ~creation_time:(Cookeio.creation_time c) ~last_access:now () 208 220 else c) 209 221 cookies ··· 319 331 320 332 List.iter 321 333 (fun cookie -> 322 - let include_subdomains = 323 - if String.starts_with ~prefix:"." (Cookeio.domain cookie) then "TRUE" 324 - else "FALSE" 325 - in 334 + (* Mozilla format: include_subdomains=TRUE means host_only=false *) 335 + let include_subdomains = if Cookeio.host_only cookie then "FALSE" else "TRUE" in 326 336 let secure_flag = if Cookeio.secure cookie then "TRUE" else "FALSE" in 327 337 let expires_str = 328 338 match Cookeio.expires cookie with ··· 357 367 let line = String.trim line in 358 368 if line <> "" && not (String.starts_with ~prefix:"#" line) then 359 369 match String.split_on_char '\t' line with 360 - | [ domain; _include_subdomains; path; secure; expires; name; value ] -> 370 + | [ domain; include_subdomains; path; secure; expires; name; value ] -> 361 371 let now = 362 372 Ptime.of_float_s (Eio.Time.now clock) 363 373 |> Option.value ~default:Ptime.epoch ··· 370 380 | Some t -> Some (`DateTime t) 371 381 | None -> None 372 382 in 383 + (* Mozilla format: include_subdomains=TRUE means host_only=false *) 384 + let host_only = include_subdomains <> "TRUE" in 373 385 374 386 let cookie = 375 387 Cookeio.make ~domain:(normalize_domain domain) ~path ~name ~value 376 388 ~secure:(secure = "TRUE") ~http_only:false ?expires 377 - ?max_age:None ?same_site:None ~partitioned:false 389 + ?max_age:None ?same_site:None ~partitioned:false ~host_only 378 390 ~creation_time:now ~last_access:now () 379 391 in 380 392 add_original jar cookie;
+348
test/test_cookeio.ml
··· 1885 1885 end 1886 1886 | None -> Alcotest.fail "Should parse cookie with both attributes" 1887 1887 1888 + (* ============================================================================ *) 1889 + (* Host-Only Flag Tests (RFC 6265 Section 5.3) *) 1890 + (* ============================================================================ *) 1891 + 1892 + let test_host_only_without_domain_attribute () = 1893 + Eio_mock.Backend.run @@ fun () -> 1894 + let clock = Eio_mock.Clock.make () in 1895 + Eio_mock.Clock.set_time clock 1000.0; 1896 + 1897 + (* Cookie without Domain attribute should have host_only=true *) 1898 + let header = "session=abc123; Secure; HttpOnly" in 1899 + let cookie_opt = 1900 + of_set_cookie_header 1901 + ~now:(fun () -> 1902 + Ptime.of_float_s (Eio.Time.now clock) 1903 + |> Option.value ~default:Ptime.epoch) 1904 + ~domain:"example.com" ~path:"/" header 1905 + in 1906 + Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt); 1907 + let cookie = Option.get cookie_opt in 1908 + Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie); 1909 + Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie) 1910 + 1911 + let test_host_only_with_domain_attribute () = 1912 + Eio_mock.Backend.run @@ fun () -> 1913 + let clock = Eio_mock.Clock.make () in 1914 + Eio_mock.Clock.set_time clock 1000.0; 1915 + 1916 + (* Cookie with Domain attribute should have host_only=false *) 1917 + let header = "session=abc123; Domain=example.com; Secure" in 1918 + let cookie_opt = 1919 + of_set_cookie_header 1920 + ~now:(fun () -> 1921 + Ptime.of_float_s (Eio.Time.now clock) 1922 + |> Option.value ~default:Ptime.epoch) 1923 + ~domain:"example.com" ~path:"/" header 1924 + in 1925 + Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt); 1926 + let cookie = Option.get cookie_opt in 1927 + Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie); 1928 + Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie) 1929 + 1930 + let test_host_only_with_dotted_domain_attribute () = 1931 + Eio_mock.Backend.run @@ fun () -> 1932 + let clock = Eio_mock.Clock.make () in 1933 + Eio_mock.Clock.set_time clock 1000.0; 1934 + 1935 + (* Cookie with .domain should have host_only=false and normalized domain *) 1936 + let header = "session=abc123; Domain=.example.com" in 1937 + let cookie_opt = 1938 + of_set_cookie_header 1939 + ~now:(fun () -> 1940 + Ptime.of_float_s (Eio.Time.now clock) 1941 + |> Option.value ~default:Ptime.epoch) 1942 + ~domain:"example.com" ~path:"/" header 1943 + in 1944 + Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt); 1945 + let cookie = Option.get cookie_opt in 1946 + Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie); 1947 + Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie) 1948 + 1949 + let test_host_only_domain_matching () = 1950 + Eio_mock.Backend.run @@ fun () -> 1951 + let clock = Eio_mock.Clock.make () in 1952 + Eio_mock.Clock.set_time clock 1000.0; 1953 + 1954 + let jar = create () in 1955 + 1956 + (* Add a host-only cookie (no Domain attribute) *) 1957 + let host_only_cookie = 1958 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1" 1959 + ~secure:false ~http_only:false ~host_only:true 1960 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1961 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 1962 + in 1963 + add_cookie jar host_only_cookie; 1964 + 1965 + (* Add a domain cookie (with Domain attribute) *) 1966 + let domain_cookie = 1967 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2" 1968 + ~secure:false ~http_only:false ~host_only:false 1969 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 1970 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 1971 + in 1972 + add_cookie jar domain_cookie; 1973 + 1974 + (* Both cookies should match exact domain *) 1975 + let cookies_exact = 1976 + get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false 1977 + in 1978 + Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact); 1979 + 1980 + (* Only domain cookie should match subdomain *) 1981 + let cookies_sub = 1982 + get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false 1983 + in 1984 + Alcotest.(check int) "only domain cookie matches subdomain" 1 (List.length cookies_sub); 1985 + let sub_cookie = List.hd cookies_sub in 1986 + Alcotest.(check string) "subdomain match is domain cookie" "domain" (Cookeio.name sub_cookie) 1987 + 1988 + let test_host_only_cookie_header_parsing () = 1989 + Eio_mock.Backend.run @@ fun () -> 1990 + let clock = Eio_mock.Clock.make () in 1991 + Eio_mock.Clock.set_time clock 1000.0; 1992 + 1993 + (* Cookies from Cookie header should have host_only=true *) 1994 + let results = 1995 + of_cookie_header 1996 + ~now:(fun () -> 1997 + Ptime.of_float_s (Eio.Time.now clock) 1998 + |> Option.value ~default:Ptime.epoch) 1999 + ~domain:"example.com" ~path:"/" "session=abc; theme=dark" 2000 + in 2001 + let cookies = List.filter_map Result.to_option results in 2002 + Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies); 2003 + List.iter (fun c -> 2004 + Alcotest.(check bool) 2005 + ("host_only is true for " ^ Cookeio.name c) 2006 + true (Cookeio.host_only c) 2007 + ) cookies 2008 + 2009 + let test_host_only_mozilla_format_round_trip () = 2010 + Eio_mock.Backend.run @@ fun () -> 2011 + let clock = Eio_mock.Clock.make () in 2012 + Eio_mock.Clock.set_time clock 1000.0; 2013 + 2014 + let jar = create () in 2015 + 2016 + (* Add host-only cookie *) 2017 + let host_only = 2018 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1" 2019 + ~secure:false ~http_only:false ~host_only:true 2020 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2021 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2022 + in 2023 + add_cookie jar host_only; 2024 + 2025 + (* Add domain cookie *) 2026 + let domain_cookie = 2027 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2" 2028 + ~secure:false ~http_only:false ~host_only:false 2029 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2030 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2031 + in 2032 + add_cookie jar domain_cookie; 2033 + 2034 + (* Round trip through Mozilla format *) 2035 + let mozilla = to_mozilla_format jar in 2036 + let jar2 = from_mozilla_format ~clock mozilla in 2037 + let cookies = get_all_cookies jar2 in 2038 + 2039 + Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies); 2040 + 2041 + let find name_val = List.find (fun c -> Cookeio.name c = name_val) cookies in 2042 + Alcotest.(check bool) "hostonly preserved" true (Cookeio.host_only (find "hostonly")); 2043 + Alcotest.(check bool) "domain preserved" false (Cookeio.host_only (find "domain")) 2044 + 2045 + (* ============================================================================ *) 2046 + (* Path Matching Tests (RFC 6265 Section 5.1.4) *) 2047 + (* ============================================================================ *) 2048 + 2049 + let test_path_matching_identical () = 2050 + Eio_mock.Backend.run @@ fun () -> 2051 + let clock = Eio_mock.Clock.make () in 2052 + Eio_mock.Clock.set_time clock 1000.0; 2053 + 2054 + let jar = create () in 2055 + let cookie = 2056 + Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val" 2057 + ~secure:false ~http_only:false 2058 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2059 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2060 + in 2061 + add_cookie jar cookie; 2062 + 2063 + (* Identical path should match *) 2064 + let cookies = 2065 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false 2066 + in 2067 + Alcotest.(check int) "identical path matches" 1 (List.length cookies) 2068 + 2069 + let test_path_matching_with_trailing_slash () = 2070 + Eio_mock.Backend.run @@ fun () -> 2071 + let clock = Eio_mock.Clock.make () in 2072 + Eio_mock.Clock.set_time clock 1000.0; 2073 + 2074 + let jar = create () in 2075 + let cookie = 2076 + Cookeio.make ~domain:"example.com" ~path:"/foo/" ~name:"test" ~value:"val" 2077 + ~secure:false ~http_only:false 2078 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2079 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2080 + in 2081 + add_cookie jar cookie; 2082 + 2083 + (* Cookie path /foo/ should match /foo/bar *) 2084 + let cookies = 2085 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false 2086 + in 2087 + Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies); 2088 + 2089 + (* Cookie path /foo/ should match /foo/ *) 2090 + let cookies2 = 2091 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false 2092 + in 2093 + Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2) 2094 + 2095 + let test_path_matching_prefix_with_slash () = 2096 + Eio_mock.Backend.run @@ fun () -> 2097 + let clock = Eio_mock.Clock.make () in 2098 + Eio_mock.Clock.set_time clock 1000.0; 2099 + 2100 + let jar = create () in 2101 + let cookie = 2102 + Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val" 2103 + ~secure:false ~http_only:false 2104 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2105 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2106 + in 2107 + add_cookie jar cookie; 2108 + 2109 + (* Cookie path /foo should match /foo/bar (next char is /) *) 2110 + let cookies = 2111 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false 2112 + in 2113 + Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies); 2114 + 2115 + (* Cookie path /foo should match /foo/ *) 2116 + let cookies2 = 2117 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false 2118 + in 2119 + Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2) 2120 + 2121 + let test_path_matching_no_false_prefix () = 2122 + Eio_mock.Backend.run @@ fun () -> 2123 + let clock = Eio_mock.Clock.make () in 2124 + Eio_mock.Clock.set_time clock 1000.0; 2125 + 2126 + let jar = create () in 2127 + let cookie = 2128 + Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val" 2129 + ~secure:false ~http_only:false 2130 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2131 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2132 + in 2133 + add_cookie jar cookie; 2134 + 2135 + (* Cookie path /foo should NOT match /foobar (no / separator) *) 2136 + let cookies = 2137 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foobar" ~is_secure:false 2138 + in 2139 + Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies); 2140 + 2141 + (* Cookie path /foo should NOT match /foob *) 2142 + let cookies2 = 2143 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foob" ~is_secure:false 2144 + in 2145 + Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2) 2146 + 2147 + let test_path_matching_root () = 2148 + Eio_mock.Backend.run @@ fun () -> 2149 + let clock = Eio_mock.Clock.make () in 2150 + Eio_mock.Clock.set_time clock 1000.0; 2151 + 2152 + let jar = create () in 2153 + let cookie = 2154 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"val" 2155 + ~secure:false ~http_only:false 2156 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2157 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2158 + in 2159 + add_cookie jar cookie; 2160 + 2161 + (* Root path should match everything *) 2162 + let cookies1 = 2163 + get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false 2164 + in 2165 + Alcotest.(check int) "/ matches /" 1 (List.length cookies1); 2166 + 2167 + let cookies2 = 2168 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false 2169 + in 2170 + Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2); 2171 + 2172 + let cookies3 = 2173 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false 2174 + in 2175 + Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3) 2176 + 2177 + let test_path_matching_no_match () = 2178 + Eio_mock.Backend.run @@ fun () -> 2179 + let clock = Eio_mock.Clock.make () in 2180 + Eio_mock.Clock.set_time clock 1000.0; 2181 + 2182 + let jar = create () in 2183 + let cookie = 2184 + Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"test" ~value:"val" 2185 + ~secure:false ~http_only:false 2186 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 2187 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) () 2188 + in 2189 + add_cookie jar cookie; 2190 + 2191 + (* Cookie path /foo/bar should NOT match /foo *) 2192 + let cookies = 2193 + get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false 2194 + in 2195 + Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies); 2196 + 2197 + (* Cookie path /foo/bar should NOT match / *) 2198 + let cookies2 = 2199 + get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false 2200 + in 2201 + Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2); 2202 + 2203 + (* Cookie path /foo/bar should NOT match /baz *) 2204 + let cookies3 = 2205 + get_cookies jar ~clock ~domain:"example.com" ~path:"/baz" ~is_secure:false 2206 + in 2207 + Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3) 2208 + 1888 2209 let () = 1889 2210 Eio_main.run @@ fun env -> 1890 2211 let open Alcotest in ··· 2018 2339 test_max_age_and_expires_both_present env); 2019 2340 test_case "parse both" `Quick (fun () -> 2020 2341 test_parse_max_age_and_expires env); 2342 + ] ); 2343 + ( "host_only_flag", 2344 + [ 2345 + test_case "host_only without Domain attribute" `Quick 2346 + test_host_only_without_domain_attribute; 2347 + test_case "host_only with Domain attribute" `Quick 2348 + test_host_only_with_domain_attribute; 2349 + test_case "host_only with dotted Domain attribute" `Quick 2350 + test_host_only_with_dotted_domain_attribute; 2351 + test_case "host_only domain matching" `Quick 2352 + test_host_only_domain_matching; 2353 + test_case "host_only Cookie header parsing" `Quick 2354 + test_host_only_cookie_header_parsing; 2355 + test_case "host_only Mozilla format round trip" `Quick 2356 + test_host_only_mozilla_format_round_trip; 2357 + ] ); 2358 + ( "path_matching", 2359 + [ 2360 + test_case "identical path" `Quick test_path_matching_identical; 2361 + test_case "path with trailing slash" `Quick 2362 + test_path_matching_with_trailing_slash; 2363 + test_case "prefix with slash separator" `Quick 2364 + test_path_matching_prefix_with_slash; 2365 + test_case "no false prefix match" `Quick 2366 + test_path_matching_no_false_prefix; 2367 + test_case "root path matches all" `Quick test_path_matching_root; 2368 + test_case "path no match" `Quick test_path_matching_no_match; 2021 2369 ] ); 2022 2370 ]