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

rfc complaince

Changed files
+416 -26
lib
test
+22 -12
lib/core/cookeio.ml
···
secure : bool;
http_only : bool;
partitioned : bool;
+
host_only : bool;
expires : Expiration.t option;
max_age : Ptime.Span.t option;
same_site : SameSite.t option;
···
let secure cookie = cookie.secure
let http_only cookie = cookie.http_only
let partitioned cookie = cookie.partitioned
+
let host_only cookie = cookie.host_only
let expires cookie = cookie.expires
let max_age cookie = cookie.max_age
let same_site cookie = cookie.same_site
···
let last_access cookie = cookie.last_access
let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
-
?expires ?max_age ?same_site ?(partitioned = false) ~creation_time
-
~last_access () =
+
?expires ?max_age ?same_site ?(partitioned = false) ?(host_only = false)
+
~creation_time ~last_access () =
{
domain;
path;
···
secure;
http_only;
partitioned;
+
host_only;
expires;
max_age;
same_site;
···
in
samesite_valid && partitioned_valid
-
(** Build final cookie from name/value and accumulated attributes *)
+
(** Build final cookie from name/value and accumulated attributes.
+
Per RFC 6265 Section 5.3:
+
- If Domain attribute is present, host_only = false, domain = attribute value
+
- If Domain attribute is absent, host_only = true, domain = request host *)
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
-
let domain =
-
normalize_domain (Option.value attrs.domain ~default:request_domain)
+
let host_only, domain =
+
match attrs.domain with
+
| Some d -> (false, normalize_domain d)
+
| None -> (true, request_domain)
in
let path = Option.value attrs.path ~default:request_path in
make ~domain ~path ~name ~value ~secure:attrs.secure
~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
-
?same_site:attrs.same_site ~partitioned:attrs.partitioned ~creation_time:now
-
~last_access:now ()
+
?same_site:attrs.same_site ~partitioned:attrs.partitioned ~host_only
+
~creation_time:now ~last_access:now ()
(** {1 Pretty Printing} *)
let pp ppf cookie =
Format.fprintf ppf
"@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
-
http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a \
-
}@]"
+
http_only=%b;@ partitioned=%b;@ host_only=%b;@ expires=%a;@ max_age=%a;@ \
+
same_site=%a }@]"
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
-
(http_only cookie) (partitioned cookie)
+
(http_only cookie) (partitioned cookie) (host_only cookie)
(Format.pp_print_option Expiration.pp)
(expires cookie)
(Format.pp_print_option Ptime.Span.pp)
···
|> String.trim
in
let current_time = now () in
-
(* Create cookie with defaults from Cookie header context *)
+
(* Create cookie with defaults from Cookie header context.
+
Cookies from Cookie headers have host_only=true since we don't
+
know if they originally had a Domain attribute. *)
let cookie =
make ~domain ~path ~name:cookie_name ~value:cookie_value
-
~secure:false ~http_only:false ~partitioned:false
+
~secure:false ~http_only:false ~partitioned:false ~host_only:true
~creation_time:current_time ~last_access:current_time ()
in
Ok cookie)
+20
lib/core/cookeio.mli
···
privacy-preserving third-party cookie functionality. Partitioned cookies
must always be Secure. *)
+
val host_only : t -> bool
+
(** Check if cookie has the host-only flag set.
+
+
Per RFC 6265 Section 5.3:
+
- If the Set-Cookie header included a Domain attribute, host_only is false
+
and the cookie matches the domain and all subdomains.
+
- If no Domain attribute was present, host_only is true and the cookie
+
only matches the exact request host.
+
+
Example:
+
- Cookie set on "example.com" with Domain=example.com: host_only=false,
+
matches example.com and sub.example.com
+
- Cookie set on "example.com" without Domain attribute: host_only=true,
+
matches only example.com, not sub.example.com *)
+
val expires : t -> Expiration.t option
(** Get the expiration attribute if set.
···
?max_age:Ptime.Span.t ->
?same_site:SameSite.t ->
?partitioned:bool ->
+
?host_only:bool ->
creation_time:Ptime.t ->
last_access:Ptime.t ->
unit ->
t
(** Create a new cookie with the given attributes.
+
+
@param host_only If true, the cookie only matches the exact domain (no
+
subdomains). Defaults to false. Per RFC 6265, this should be true when no
+
Domain attribute was present in the Set-Cookie header.
Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
combinations will result in validation errors. *)
+26 -14
lib/jar/cookeio_jar.ml
···
String.sub domain 1 (String.length domain - 1)
| _ -> domain
-
let domain_matches cookie_domain request_domain =
-
(* Cookie domains are stored without leading dots per RFC 6265.
-
A cookie with domain "example.com" should match both "example.com" (exact)
-
and "sub.example.com" (subdomain). *)
+
let domain_matches ~host_only cookie_domain request_domain =
+
(* RFC 6265 Section 5.4: Domain matching for Cookie header.
+
Cookie domains are stored without leading dots per RFC 6265. *)
request_domain = cookie_domain
-
|| String.ends_with ~suffix:("." ^ cookie_domain) request_domain
+
|| (not host_only
+
&& String.ends_with ~suffix:("." ^ cookie_domain) request_domain)
let path_matches cookie_path request_path =
-
(* Cookie path /foo matches /foo, /foo/, /foo/bar *)
-
String.starts_with ~prefix:cookie_path request_path
+
(* RFC 6265 Section 5.1.4: A request-path path-matches a cookie-path if:
+
1. The cookie-path and the request-path are identical, OR
+
2. The cookie-path is a prefix of request-path AND cookie-path ends with "/", OR
+
3. The cookie-path is a prefix of request-path AND the first char of
+
request-path not in cookie-path is "/" *)
+
if cookie_path = request_path then true
+
else if String.starts_with ~prefix:cookie_path request_path then
+
let cookie_len = String.length cookie_path in
+
String.ends_with ~suffix:"/" cookie_path
+
|| (String.length request_path > cookie_len && request_path.[cookie_len] = '/')
+
else false
(** {1 HTTP Date Parsing} *)
let is_expired cookie clock =
···
~http_only:(Cookeio.http_only cookie) ~expires:(`DateTime past_expiry)
~max_age:(Ptime.Span.of_int_s 0) ?same_site:(Cookeio.same_site cookie)
~partitioned:(Cookeio.partitioned cookie)
+
~host_only:(Cookeio.host_only cookie)
~creation_time:now ~last_access:now ()
let remove jar ~clock cookie =
···
(fun cookie ->
Cookeio.value cookie <> ""
(* Exclude removal cookies *)
-
&& domain_matches (Cookeio.domain cookie) request_domain
+
&& domain_matches ~host_only:(Cookeio.host_only cookie)
+
(Cookeio.domain cookie) request_domain
&& path_matches (Cookeio.path cookie) request_path
&& ((not (Cookeio.secure cookie)) || is_secure))
unique_cookies
···
?expires:(Cookeio.expires c) ?max_age:(Cookeio.max_age c)
?same_site:(Cookeio.same_site c)
~partitioned:(Cookeio.partitioned c)
+
~host_only:(Cookeio.host_only c)
~creation_time:(Cookeio.creation_time c) ~last_access:now ()
else c)
cookies
···
List.iter
(fun cookie ->
-
let include_subdomains =
-
if String.starts_with ~prefix:"." (Cookeio.domain cookie) then "TRUE"
-
else "FALSE"
-
in
+
(* Mozilla format: include_subdomains=TRUE means host_only=false *)
+
let include_subdomains = if Cookeio.host_only cookie then "FALSE" else "TRUE" in
let secure_flag = if Cookeio.secure cookie then "TRUE" else "FALSE" in
let expires_str =
match Cookeio.expires cookie with
···
let line = String.trim line in
if line <> "" && not (String.starts_with ~prefix:"#" line) then
match String.split_on_char '\t' line with
-
| [ domain; _include_subdomains; path; secure; expires; name; value ] ->
+
| [ domain; include_subdomains; path; secure; expires; name; value ] ->
let now =
Ptime.of_float_s (Eio.Time.now clock)
|> Option.value ~default:Ptime.epoch
···
| Some t -> Some (`DateTime t)
| None -> None
in
+
(* Mozilla format: include_subdomains=TRUE means host_only=false *)
+
let host_only = include_subdomains <> "TRUE" in
let cookie =
Cookeio.make ~domain:(normalize_domain domain) ~path ~name ~value
~secure:(secure = "TRUE") ~http_only:false ?expires
-
?max_age:None ?same_site:None ~partitioned:false
+
?max_age:None ?same_site:None ~partitioned:false ~host_only
~creation_time:now ~last_access:now ()
in
add_original jar cookie;
+348
test/test_cookeio.ml
···
end
| None -> Alcotest.fail "Should parse cookie with both attributes"
+
(* ============================================================================ *)
+
(* Host-Only Flag Tests (RFC 6265 Section 5.3) *)
+
(* ============================================================================ *)
+
+
let test_host_only_without_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie without Domain attribute should have host_only=true *)
+
let header = "session=abc123; Secure; HttpOnly" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool) "host_only is true" true (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain is request host" "example.com" (Cookeio.domain cookie)
+
+
let test_host_only_with_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie with Domain attribute should have host_only=false *)
+
let header = "session=abc123; Domain=example.com; Secure" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain is attribute value" "example.com" (Cookeio.domain cookie)
+
+
let test_host_only_with_dotted_domain_attribute () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookie with .domain should have host_only=false and normalized domain *)
+
let header = "session=abc123; Domain=.example.com" in
+
let cookie_opt =
+
of_set_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool) "host_only is false" false (Cookeio.host_only cookie);
+
Alcotest.(check string) "domain normalized" "example.com" (Cookeio.domain cookie)
+
+
let test_host_only_domain_matching () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add a host-only cookie (no Domain attribute) *)
+
let host_only_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"host_only" ~value:"val1"
+
~secure:false ~http_only:false ~host_only:true
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar host_only_cookie;
+
+
(* Add a domain cookie (with Domain attribute) *)
+
let domain_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"val2"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar domain_cookie;
+
+
(* Both cookies should match exact domain *)
+
let cookies_exact =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "both match exact domain" 2 (List.length cookies_exact);
+
+
(* Only domain cookie should match subdomain *)
+
let cookies_sub =
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "only domain cookie matches subdomain" 1 (List.length cookies_sub);
+
let sub_cookie = List.hd cookies_sub in
+
Alcotest.(check string) "subdomain match is domain cookie" "domain" (Cookeio.name sub_cookie)
+
+
let test_host_only_cookie_header_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Cookies from Cookie header should have host_only=true *)
+
let results =
+
of_cookie_header
+
~now:(fun () ->
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch)
+
~domain:"example.com" ~path:"/" "session=abc; theme=dark"
+
in
+
let cookies = List.filter_map Result.to_option results in
+
Alcotest.(check int) "parsed 2 cookies" 2 (List.length cookies);
+
List.iter (fun c ->
+
Alcotest.(check bool)
+
("host_only is true for " ^ Cookeio.name c)
+
true (Cookeio.host_only c)
+
) cookies
+
+
let test_host_only_mozilla_format_round_trip () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add host-only cookie *)
+
let host_only =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"hostonly" ~value:"v1"
+
~secure:false ~http_only:false ~host_only:true
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar host_only;
+
+
(* Add domain cookie *)
+
let domain_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"domain" ~value:"v2"
+
~secure:false ~http_only:false ~host_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar domain_cookie;
+
+
(* Round trip through Mozilla format *)
+
let mozilla = to_mozilla_format jar in
+
let jar2 = from_mozilla_format ~clock mozilla in
+
let cookies = get_all_cookies jar2 in
+
+
Alcotest.(check int) "2 cookies after round trip" 2 (List.length cookies);
+
+
let find name_val = List.find (fun c -> Cookeio.name c = name_val) cookies in
+
Alcotest.(check bool) "hostonly preserved" true (Cookeio.host_only (find "hostonly"));
+
Alcotest.(check bool) "domain preserved" false (Cookeio.host_only (find "domain"))
+
+
(* ============================================================================ *)
+
(* Path Matching Tests (RFC 6265 Section 5.1.4) *)
+
(* ============================================================================ *)
+
+
let test_path_matching_identical () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Identical path should match *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
in
+
Alcotest.(check int) "identical path matches" 1 (List.length cookies)
+
+
let test_path_matching_with_trailing_slash () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/foo/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Cookie path /foo/ should match /foo/bar *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/ matches /foo/bar" 1 (List.length cookies);
+
+
(* Cookie path /foo/ should match /foo/ *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/ matches /foo/" 1 (List.length cookies2)
+
+
let test_path_matching_prefix_with_slash () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Cookie path /foo should match /foo/bar (next char is /) *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo matches /foo/bar" 1 (List.length cookies);
+
+
(* Cookie path /foo should match /foo/ *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo matches /foo/" 1 (List.length cookies2)
+
+
let test_path_matching_no_false_prefix () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/foo" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Cookie path /foo should NOT match /foobar (no / separator) *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foobar" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo does NOT match /foobar" 0 (List.length cookies);
+
+
(* Cookie path /foo should NOT match /foob *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foob" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo does NOT match /foob" 0 (List.length cookies2)
+
+
let test_path_matching_root () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Root path should match everything *)
+
let cookies1 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "/ matches /" 1 (List.length cookies1);
+
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
in
+
Alcotest.(check int) "/ matches /foo" 1 (List.length cookies2);
+
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo/bar/baz" ~is_secure:false
+
in
+
Alcotest.(check int) "/ matches /foo/bar/baz" 1 (List.length cookies3)
+
+
let test_path_matching_no_match () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/foo/bar" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get) ()
+
in
+
add_cookie jar cookie;
+
+
(* Cookie path /foo/bar should NOT match /foo *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/foo" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/bar does NOT match /foo" 0 (List.length cookies);
+
+
(* Cookie path /foo/bar should NOT match / *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/bar does NOT match /" 0 (List.length cookies2);
+
+
(* Cookie path /foo/bar should NOT match /baz *)
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/baz" ~is_secure:false
+
in
+
Alcotest.(check int) "/foo/bar does NOT match /baz" 0 (List.length cookies3)
+
let () =
Eio_main.run @@ fun env ->
let open Alcotest in
···
test_max_age_and_expires_both_present env);
test_case "parse both" `Quick (fun () ->
test_parse_max_age_and_expires env);
+
] );
+
( "host_only_flag",
+
[
+
test_case "host_only without Domain attribute" `Quick
+
test_host_only_without_domain_attribute;
+
test_case "host_only with Domain attribute" `Quick
+
test_host_only_with_domain_attribute;
+
test_case "host_only with dotted Domain attribute" `Quick
+
test_host_only_with_dotted_domain_attribute;
+
test_case "host_only domain matching" `Quick
+
test_host_only_domain_matching;
+
test_case "host_only Cookie header parsing" `Quick
+
test_host_only_cookie_header_parsing;
+
test_case "host_only Mozilla format round trip" `Quick
+
test_host_only_mozilla_format_round_trip;
+
] );
+
( "path_matching",
+
[
+
test_case "identical path" `Quick test_path_matching_identical;
+
test_case "path with trailing slash" `Quick
+
test_path_matching_with_trailing_slash;
+
test_case "prefix with slash separator" `Quick
+
test_path_matching_prefix_with_slash;
+
test_case "no false prefix match" `Quick
+
test_path_matching_no_false_prefix;
+
test_case "root path matches all" `Quick test_path_matching_root;
+
test_case "path no match" `Quick test_path_matching_no_match;
] );