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

break eio dep

+2 -3
cookeio.opam
···
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
-
synopsis: "Cookie parsing and management library using Eio"
description:
-
"Cookeio provides cookie management functionality for OCaml applications, including parsing Set-Cookie headers, managing cookie jars, and supporting the Mozilla cookies.txt format for persistence."
maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
authors: ["Anil Madhavapeddy"]
license: "ISC"
···
depends: [
"ocaml" {>= "5.2.0"}
"dune" {>= "3.20"}
-
"eio" {>= "1.0"}
"logs" {>= "0.9.0"}
"ptime" {>= "1.1.0"}
"eio_main" {with-test}
···
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
+
synopsis: "Cookie parsing and management library"
description:
+
"Cookeio provides cookie parsing and serialization for OCaml applications. It handles parsing Set-Cookie and Cookie headers with full support for all cookie attributes."
maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
authors: ["Anil Madhavapeddy"]
license: "ISC"
···
depends: [
"ocaml" {>= "5.2.0"}
"dune" {>= "3.20"}
"logs" {>= "0.9.0"}
"ptime" {>= "1.1.0"}
"eio_main" {with-test}
+2 -3
dune-project
···
(package
(name cookeio)
-
(synopsis "Cookie parsing and management library using Eio")
-
(description "Cookeio provides cookie management functionality for OCaml applications, including parsing Set-Cookie headers, managing cookie jars, and supporting the Mozilla cookies.txt format for persistence.")
(depends
(ocaml (>= 5.2.0))
dune
-
(eio (>= 1.0))
(logs (>= 0.9.0))
(ptime (>= 1.1.0))
(eio_main :with-test)
···
(package
(name cookeio)
+
(synopsis "Cookie parsing and management library")
+
(description "Cookeio provides cookie parsing and serialization for OCaml applications. It handles parsing Set-Cookie and Cookie headers with full support for all cookie attributes.")
(depends
(ocaml (>= 5.2.0))
dune
(logs (>= 0.9.0))
(ptime (>= 1.1.0))
(eio_main :with-test)
+12 -18
lib/core/cookeio.ml
···
}
(** Parse a single attribute and update the accumulator in-place *)
-
let parse_attribute clock attrs attr_name attr_value =
let attr_lower = String.lowercase_ascii attr_name in
match attr_lower with
| "domain" -> attrs.domain <- Some (normalize_domain attr_value)
···
| Some seconds ->
(* Handle negative values as 0 per RFC 6265 *)
let seconds = max 0 seconds in
-
let now = Eio.Time.now clock in
(* Store the max-age as a Ptime.Span *)
attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
(* Also compute and store expires as DateTime *)
-
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
(match expires with
| Some time -> attrs.expires <- Some (`DateTime time)
| None -> ());
···
(** {1 Cookie Parsing} *)
-
let of_set_cookie_header ~clock ~domain:request_domain ~path:request_path
header_value =
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
···
|> String.trim
in
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> Option.value ~default:Ptime.epoch
-
in
(* Parse all attributes into mutable accumulator *)
let accumulated_attrs = empty_attributes () in
···
match String.index_opt attr '=' with
| None ->
(* Attribute without value (e.g., Secure, HttpOnly) *)
-
parse_attribute clock accumulated_attrs attr ""
| Some eq ->
let attr_name = String.sub attr 0 eq |> String.trim in
let attr_value =
String.sub attr (eq + 1) (String.length attr - eq - 1)
|> String.trim
in
-
parse_attribute clock accumulated_attrs attr_name attr_value)
attrs;
(* Validate attributes *)
···
else
let cookie =
build_cookie ~request_domain ~request_path ~name
-
~value:cookie_value accumulated_attrs ~now
in
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
Some cookie)
-
let of_cookie_header ~clock ~domain ~path header_value =
Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
(* Split on semicolons *)
···
(String.length name_value - eq_pos - 1)
|> String.trim
in
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> Option.value ~default:Ptime.epoch
-
in
(* Create cookie with defaults from Cookie header context *)
let cookie =
make ~domain ~path ~name:cookie_name ~value:cookie_value
-
~secure:false ~http_only:false ~partitioned:false ~creation_time:now
-
~last_access:now ()
in
Ok cookie)
parts
···
}
(** Parse a single attribute and update the accumulator in-place *)
+
let parse_attribute now attrs attr_name attr_value =
let attr_lower = String.lowercase_ascii attr_name in
match attr_lower with
| "domain" -> attrs.domain <- Some (normalize_domain attr_value)
···
| Some seconds ->
(* Handle negative values as 0 per RFC 6265 *)
let seconds = max 0 seconds in
+
let current_time = now () in
(* Store the max-age as a Ptime.Span *)
attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
(* Also compute and store expires as DateTime *)
+
let expires = Ptime.add_span current_time (Ptime.Span.of_int_s seconds) in
(match expires with
| Some time -> attrs.expires <- Some (`DateTime time)
| None -> ());
···
(** {1 Cookie Parsing} *)
+
let of_set_cookie_header ~now ~domain:request_domain ~path:request_path
header_value =
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
···
|> String.trim
in
+
let current_time = now () in
(* Parse all attributes into mutable accumulator *)
let accumulated_attrs = empty_attributes () in
···
match String.index_opt attr '=' with
| None ->
(* Attribute without value (e.g., Secure, HttpOnly) *)
+
parse_attribute now accumulated_attrs attr ""
| Some eq ->
let attr_name = String.sub attr 0 eq |> String.trim in
let attr_value =
String.sub attr (eq + 1) (String.length attr - eq - 1)
|> String.trim
in
+
parse_attribute now accumulated_attrs attr_name attr_value)
attrs;
(* Validate attributes *)
···
else
let cookie =
build_cookie ~request_domain ~request_path ~name
+
~value:cookie_value accumulated_attrs ~now:current_time
in
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
Some cookie)
+
let of_cookie_header ~now ~domain ~path header_value =
Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
(* Split on semicolons *)
···
(String.length name_value - eq_pos - 1)
|> String.trim
in
+
let current_time = now () in
(* Create cookie with defaults from Cookie header context *)
let cookie =
make ~domain ~path ~name:cookie_name ~value:cookie_value
+
~secure:false ~http_only:false ~partitioned:false ~creation_time:current_time
+
~last_access:current_time ()
in
Ok cookie)
parts
+7 -7
lib/core/cookeio.mli
···
(** {1 Cookie Creation and Parsing} *)
val of_set_cookie_header :
-
clock:_ Eio.Time.clock -> domain:string -> path:string -> string -> t option
(** Parse Set-Cookie response header value into a cookie.
Set-Cookie headers are sent from server to client and contain the cookie
···
- Returns [None] if parsing fails or cookie validation fails
- The [domain] and [path] parameters provide the request context for default
values
-
- The [clock] parameter is used for calculating expiry times from [max-age]
-
attributes
Cookie validation rules:
- [SameSite=None] requires the [Secure] flag to be set
- [Partitioned] requires the [Secure] flag to be set
Example:
-
[of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" "session=abc123;
Secure; HttpOnly"] *)
val of_cookie_header :
-
clock:_ Eio.Time.clock ->
domain:string ->
path:string ->
string ->
···
- Provided [domain] and [path] from request context
- All security flags set to [false] (defaults)
- All optional attributes set to [None]
-
- [creation_time] and [last_access] set to current time from [clock]
Returns a list of parse results, one per cookie. Parse errors for individual
cookies are returned as [Error msg] without failing the entire parse. Empty
values and excess whitespace are ignored.
Example:
-
[of_cookie_header ~clock ~domain:"example.com" ~path:"/"
"session=abc; theme=dark"] *)
val make_cookie_header : t list -> string
···
(** {1 Cookie Creation and Parsing} *)
val of_set_cookie_header :
+
now:(unit -> Ptime.t) -> domain:string -> path:string -> string -> t option
(** Parse Set-Cookie response header value into a cookie.
Set-Cookie headers are sent from server to client and contain the cookie
···
- Returns [None] if parsing fails or cookie validation fails
- The [domain] and [path] parameters provide the request context for default
values
+
- The [now] parameter is used for calculating expiry times from [max-age]
+
attributes and setting creation/access times
Cookie validation rules:
- [SameSite=None] requires the [Secure] flag to be set
- [Partitioned] requires the [Secure] flag to be set
Example:
+
[of_set_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com" ~path:"/" "session=abc123;
Secure; HttpOnly"] *)
val of_cookie_header :
+
now:(unit -> Ptime.t) ->
domain:string ->
path:string ->
string ->
···
- Provided [domain] and [path] from request context
- All security flags set to [false] (defaults)
- All optional attributes set to [None]
+
- [creation_time] and [last_access] set to current time from [now]
Returns a list of parse results, one per cookie. Parse errors for individual
cookies are returned as [Error msg] without failing the entire parse. Empty
values and excess whitespace are ignored.
Example:
+
[of_cookie_header ~now:(fun () -> Ptime_clock.now ()) ~domain:"example.com" ~path:"/"
"session=abc; theme=dark"] *)
val make_cookie_header : t list -> string
+1 -1
lib/core/dune
···
(library
(name cookeio)
(public_name cookeio)
-
(libraries eio logs ptime unix))
···
(library
(name cookeio)
(public_name cookeio)
+
(libraries logs ptime))
+34 -34
test/test_cookeio.ml
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
···
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
···
(* This should be rejected: SameSite=None without Secure *)
let invalid_header = "token=abc; SameSite=None" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" invalid_header
in
Alcotest.(check bool)
···
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
let cookie_opt2 =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" valid_header
in
Alcotest.(check bool)
···
(* Test parsing ".example.com" stores as "example.com" *)
let header = "test=value; Domain=.example.com" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
let cookie = Option.get cookie_opt in
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
···
(* Parse a Set-Cookie header with negative Max-Age *)
let header = "session=abc123; Max-Age=-100" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
···
(* Parse a cookie with Max-Age *)
let header = "session=xyz; Max-Age=7200; Secure; HttpOnly" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
let cookie = Option.get cookie_opt in
···
Eio_mock.Clock.set_time clock 5000.0;
(* Reset clock to same time *)
let cookie2_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" set_cookie_header
in
Alcotest.(check bool) "cookie re-parsed" true (Option.is_some cookie2_opt);
let cookie2 = Option.get cookie2_opt in
···
(* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "FMT1 cookie parsed" true (Option.is_some cookie_opt);
···
(* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *)
let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "FMT2 cookie parsed" true (Option.is_some cookie_opt);
···
(* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "FMT3 cookie parsed" true (Option.is_some cookie_opt);
···
(* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "FMT4 cookie parsed" true (Option.is_some cookie_opt);
···
(* Year 95 should become 1995 *)
let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
let cookie = Option.get cookie_opt in
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
···
(* Year 69 should become 1969 *)
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
let cookie_opt2 =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header2
in
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
···
(* Year 99 should become 1999 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
let cookie_opt3 =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header3
in
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
···
(* Year 25 should become 2025 *)
let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
let cookie = Option.get cookie_opt in
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
···
(* Year 0 should become 2000 *)
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
let cookie_opt2 =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header2
in
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
···
(* Year 68 should become 2068 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
let cookie_opt3 =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header3
in
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
···
(* Ensure RFC 3339 format still works for backward compatibility *)
let header = "session=abc; Expires=2025-10-21T07:28:00Z" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool)
"RFC 3339 cookie parsed" true
···
(* Invalid date format should log a warning but still parse the cookie *)
let header = "session=abc; Expires=InvalidDate" in
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
(* Cookie should still be parsed, just without expires *)
···
List.iter
(fun (header, description) ->
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool)
(description ^ " parsed") true
···
List.iter
(fun (header, description) ->
let cookie_opt =
-
of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool)
(description ^ " parsed") true
···
let test_partitioned_parsing env =
let clock = Eio.Stdenv.clock env in
-
match of_set_cookie_header ~clock ~domain:"widget.com" ~path:"/"
"id=123; Partitioned; Secure" with
| Some c ->
Alcotest.(check bool) "partitioned flag" true (partitioned c);
···
let clock = Eio.Stdenv.clock env in
(* Partitioned without Secure should be rejected *)
-
match of_set_cookie_header ~clock ~domain:"widget.com" ~path:"/"
"id=123; Partitioned" with
| None -> () (* Expected *)
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
···
let clock = Eio.Stdenv.clock env in
(* Expires=0 should parse as Session *)
-
match of_set_cookie_header ~clock ~domain:"ex.com" ~path:"/"
"id=123; Expires=0" with
| Some c ->
Alcotest.(check (option expiration_testable)) "expires=0 is session"
···
] in
List.iter (fun (input, expected_raw, expected_trimmed) ->
-
match of_set_cookie_header ~clock ~domain:"ex.com" ~path:"/" input with
| Some c ->
Alcotest.(check string)
(Printf.sprintf "raw value for %s" input) expected_raw (value c);
···
let test_trimmed_value_not_used_for_equality env =
let clock = Eio.Stdenv.clock env in
-
match of_set_cookie_header ~clock ~domain:"ex.com" ~path:"/"
"name=\"value\"" with
| Some c1 ->
-
begin match of_set_cookie_header ~clock ~domain:"ex.com" ~path:"/"
"name=value" with
| Some c2 ->
(* Different raw values *)
···
let test_cookie_header_parsing_basic env =
let clock = Eio.Stdenv.clock env in
-
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
"session=abc123; theme=dark; lang=en" in
let cookies = List.filter_map Result.to_option results in
···
let test_cookie_header_defaults env =
let clock = Eio.Stdenv.clock env in
-
match of_cookie_header ~clock ~domain:"example.com" ~path:"/app"
"session=xyz" with
| [Ok c] ->
(* Domain and path from request context *)
···
let clock = Eio.Stdenv.clock env in
let test input expected_count description =
-
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/" input in
let cookies = List.filter_map Result.to_option results in
Alcotest.(check int) description expected_count (List.length cookies)
in
···
let clock = Eio.Stdenv.clock env in
(* Mix of valid and invalid cookies *)
-
let results = of_cookie_header ~clock ~domain:"ex.com" ~path:"/"
"valid=1;=noname;valid2=2" in
Alcotest.(check int) "total results" 3 (List.length results);
···
let clock = Eio.Stdenv.clock env in
(* Parse Set-Cookie with both attributes *)
-
match of_set_cookie_header ~clock ~domain:"ex.com" ~path:"/"
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT" with
| Some c ->
(* Both should be stored *)
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600; 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);
···
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; 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);
···
(* This should be rejected: SameSite=None without Secure *)
let invalid_header = "token=abc; SameSite=None" 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:"/" invalid_header
in
Alcotest.(check bool)
···
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
let cookie_opt2 =
+
of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/" valid_header
in
Alcotest.(check bool)
···
(* Test parsing ".example.com" stores as "example.com" *)
let header = "test=value; 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
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600" 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);
···
(* Parse a Set-Cookie header with negative Max-Age *)
let header = "session=abc123; Max-Age=-100" 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);
···
(* Parse a cookie with Max-Age *)
let header = "session=xyz; Max-Age=7200; 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
···
Eio_mock.Clock.set_time clock 5000.0;
(* Reset clock to same time *)
let cookie2_opt =
+
of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/" set_cookie_header
in
Alcotest.(check bool) "cookie re-parsed" true (Option.is_some cookie2_opt);
let cookie2 = Option.get cookie2_opt in
···
(* Test FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
let header = "session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT" 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) "FMT1 cookie parsed" true (Option.is_some cookie_opt);
···
(* Test FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850 with abbreviated year) *)
let header = "session=abc; Expires=Wednesday, 21-Oct-15 07:28:00 GMT" 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) "FMT2 cookie parsed" true (Option.is_some cookie_opt);
···
(* Test FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
let header = "session=abc; Expires=Wed Oct 21 07:28:00 2015" 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) "FMT3 cookie parsed" true (Option.is_some cookie_opt);
···
(* Test FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
let header = "session=abc; Expires=Wed, 21-Oct-2015 07:28:00 GMT" 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) "FMT4 cookie parsed" true (Option.is_some cookie_opt);
···
(* Year 95 should become 1995 *)
let header = "session=abc; Expires=Wed, 21-Oct-95 07:28:00 GMT" 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
let cookie = Option.get cookie_opt in
let expected = Ptime.of_date_time ((1995, 10, 21), ((07, 28, 00), 0)) in
···
(* Year 69 should become 1969 *)
let header2 = "session=abc; Expires=Wed, 10-Sep-69 20:00:00 GMT" in
let cookie_opt2 =
+
of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/" header2
in
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((1969, 9, 10), ((20, 0, 0), 0)) in
···
(* Year 99 should become 1999 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-99 20:00:00 GMT" in
let cookie_opt3 =
+
of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/" header3
in
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((1999, 9, 10), ((20, 0, 0), 0)) in
···
(* Year 25 should become 2025 *)
let header = "session=abc; Expires=Wed, 21-Oct-25 07:28:00 GMT" 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
let cookie = Option.get cookie_opt in
let expected = Ptime.of_date_time ((2025, 10, 21), ((07, 28, 00), 0)) in
···
(* Year 0 should become 2000 *)
let header2 = "session=abc; Expires=Fri, 01-Jan-00 00:00:00 GMT" in
let cookie_opt2 =
+
of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/" header2
in
let cookie2 = Option.get cookie_opt2 in
let expected2 = Ptime.of_date_time ((2000, 1, 1), ((0, 0, 0), 0)) in
···
(* Year 68 should become 2068 *)
let header3 = "session=abc; Expires=Thu, 10-Sep-68 20:00:00 GMT" in
let cookie_opt3 =
+
of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/" header3
in
let cookie3 = Option.get cookie_opt3 in
let expected3 = Ptime.of_date_time ((2068, 9, 10), ((20, 0, 0), 0)) in
···
(* Ensure RFC 3339 format still works for backward compatibility *)
let header = "session=abc; Expires=2025-10-21T07:28:00Z" 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)
"RFC 3339 cookie parsed" true
···
(* Invalid date format should log a warning but still parse the cookie *)
let header = "session=abc; Expires=InvalidDate" 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
(* Cookie should still be parsed, just without expires *)
···
List.iter
(fun (header, description) ->
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)
(description ^ " parsed") true
···
List.iter
(fun (header, description) ->
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)
(description ^ " parsed") true
···
let test_partitioned_parsing env =
let clock = Eio.Stdenv.clock env in
+
match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"widget.com" ~path:"/"
"id=123; Partitioned; Secure" with
| Some c ->
Alcotest.(check bool) "partitioned flag" true (partitioned c);
···
let clock = Eio.Stdenv.clock env in
(* Partitioned without Secure should be rejected *)
+
match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"widget.com" ~path:"/"
"id=123; Partitioned" with
| None -> () (* Expected *)
| Some _ -> Alcotest.fail "Should reject Partitioned without Secure"
···
let clock = Eio.Stdenv.clock env in
(* Expires=0 should parse as Session *)
+
match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/"
"id=123; Expires=0" with
| Some c ->
Alcotest.(check (option expiration_testable)) "expires=0 is session"
···
] in
List.iter (fun (input, expected_raw, expected_trimmed) ->
+
match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/" input with
| Some c ->
Alcotest.(check string)
(Printf.sprintf "raw value for %s" input) expected_raw (value c);
···
let test_trimmed_value_not_used_for_equality env =
let clock = Eio.Stdenv.clock env in
+
match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/"
"name=\"value\"" with
| Some c1 ->
+
begin match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/"
"name=value" with
| Some c2 ->
(* Different raw values *)
···
let test_cookie_header_parsing_basic env =
let clock = Eio.Stdenv.clock env in
+
let results = of_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/"
"session=abc123; theme=dark; lang=en" in
let cookies = List.filter_map Result.to_option results in
···
let test_cookie_header_defaults env =
let clock = Eio.Stdenv.clock env in
+
match of_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"example.com" ~path:"/app"
"session=xyz" with
| [Ok c] ->
(* Domain and path from request context *)
···
let clock = Eio.Stdenv.clock env in
let test input expected_count description =
+
let results = of_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/" input in
let cookies = List.filter_map Result.to_option results in
Alcotest.(check int) description expected_count (List.length cookies)
in
···
let clock = Eio.Stdenv.clock env in
(* Mix of valid and invalid cookies *)
+
let results = of_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/"
"valid=1;=noname;valid2=2" in
Alcotest.(check int) "total results" 3 (List.length results);
···
let clock = Eio.Stdenv.clock env in
(* Parse Set-Cookie with both attributes *)
+
match of_set_cookie_header ~now:(fun () -> Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch) ~domain:"ex.com" ~path:"/"
"id=123; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT" with
| Some c ->
(* Both should be stored *)