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

split out the jar

-858
lib/cookeio.ml
···
-
let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
-
-
module Log = (val Logs.src_log src : Logs.LOG)
-
-
module SameSite = struct
-
type t = [ `Strict | `Lax | `None ]
-
-
let equal = ( = )
-
-
let pp ppf = function
-
| `Strict -> Format.pp_print_string ppf "Strict"
-
| `Lax -> Format.pp_print_string ppf "Lax"
-
| `None -> Format.pp_print_string ppf "None"
-
end
-
-
module Expiration = struct
-
type t = [ `Session | `DateTime of Ptime.t ]
-
-
let equal e1 e2 =
-
match (e1, e2) with
-
| `Session, `Session -> true
-
| `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
-
| _ -> false
-
-
let pp ppf = function
-
| `Session -> Format.pp_print_string ppf "Session"
-
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
-
end
-
-
type t = {
-
domain : string;
-
path : string;
-
name : string;
-
value : string;
-
secure : bool;
-
http_only : bool;
-
partitioned : bool;
-
expires : Expiration.t option;
-
max_age : Ptime.Span.t option;
-
same_site : SameSite.t option;
-
creation_time : Ptime.t;
-
last_access : Ptime.t;
-
}
-
(** HTTP Cookie *)
-
-
type jar = {
-
mutable original_cookies : t list; (* from client *)
-
mutable delta_cookies : t list; (* to send back *)
-
mutex : Eio.Mutex.t;
-
}
-
(** Cookie jar for storing and managing cookies *)
-
-
(** {1 Cookie Accessors} *)
-
-
let domain cookie = cookie.domain
-
let path cookie = cookie.path
-
let name cookie = cookie.name
-
let value cookie = cookie.value
-
-
let value_trimmed cookie =
-
let v = cookie.value in
-
let len = String.length v in
-
if len < 2 then v
-
else
-
match (v.[0], v.[len - 1]) with
-
| '"', '"' -> String.sub v 1 (len - 2)
-
| _ -> v
-
-
let secure cookie = cookie.secure
-
let http_only cookie = cookie.http_only
-
let partitioned cookie = cookie.partitioned
-
let expires cookie = cookie.expires
-
let max_age cookie = cookie.max_age
-
let same_site cookie = cookie.same_site
-
let creation_time cookie = cookie.creation_time
-
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 () =
-
{
-
domain;
-
path;
-
name;
-
value;
-
secure;
-
http_only;
-
partitioned;
-
expires;
-
max_age;
-
same_site;
-
creation_time;
-
last_access;
-
}
-
-
(** {1 Cookie Jar Creation} *)
-
-
let create () =
-
Log.debug (fun m -> m "Creating new empty cookie jar");
-
{ original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () }
-
-
(** {1 Cookie Matching Helpers} *)
-
-
let cookie_identity_matches c1 c2 =
-
name c1 = name c2 && domain c1 = domain c2 && path c1 = path c2
-
-
let normalize_domain domain =
-
(* Strip leading dot per RFC 6265 *)
-
match String.starts_with ~prefix:"." domain with
-
| true when String.length domain > 1 ->
-
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). *)
-
request_domain = cookie_domain
-
|| 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
-
-
(** {1 HTTP Date Parsing} *)
-
let is_expired cookie clock =
-
match cookie.expires with
-
| None -> false (* No expiration *)
-
| Some `Session -> false (* Session cookie - not expired until browser closes *)
-
| Some (`DateTime exp_time) ->
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> Option.value ~default:Ptime.epoch
-
in
-
Ptime.compare now exp_time > 0
-
-
module DateParser = struct
-
(** Month name to number mapping (case-insensitive) *)
-
let month_of_string s =
-
match String.lowercase_ascii s with
-
| "jan" -> Some 1
-
| "feb" -> Some 2
-
| "mar" -> Some 3
-
| "apr" -> Some 4
-
| "may" -> Some 5
-
| "jun" -> Some 6
-
| "jul" -> Some 7
-
| "aug" -> Some 8
-
| "sep" -> Some 9
-
| "oct" -> Some 10
-
| "nov" -> Some 11
-
| "dec" -> Some 12
-
| _ -> None
-
-
(** Normalize abbreviated years:
-
- Years 69-99 get 1900 added (e.g., 95 → 1995)
-
- Years 0-68 get 2000 added (e.g., 25 → 2025)
-
- Years >= 100 are returned as-is *)
-
let normalize_year year =
-
if year >= 0 && year <= 68 then year + 2000
-
else if year >= 69 && year <= 99 then year + 1900
-
else year
-
-
(** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
-
let parse_fmt1 s =
-
try
-
Scanf.sscanf s "%s %d %s %d %d:%d:%d %s"
-
(fun _wday day mon year hour min sec tz ->
-
(* Check timezone is GMT (case-insensitive) *)
-
if String.lowercase_ascii tz <> "gmt" then None
-
else
-
match month_of_string mon with
-
| None -> None
-
| Some month ->
-
let year = normalize_year year in
-
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
-
with _ -> None
-
-
(** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *)
-
let parse_fmt2 s =
-
try
-
Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s"
-
(fun _wday day mon year hour min sec tz ->
-
(* Check timezone is GMT (case-insensitive) *)
-
if String.lowercase_ascii tz <> "gmt" then None
-
else
-
match month_of_string mon with
-
| None -> None
-
| Some month ->
-
let year = normalize_year year in
-
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
-
with _ -> None
-
-
(** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
-
let parse_fmt3 s =
-
try
-
Scanf.sscanf s "%s %s %d %d:%d:%d %d"
-
(fun _wday mon day hour min sec year ->
-
match month_of_string mon with
-
| None -> None
-
| Some month ->
-
let year = normalize_year year in
-
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
-
with _ -> None
-
-
(** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
-
let parse_fmt4 s =
-
try
-
Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s"
-
(fun _wday day mon year hour min sec tz ->
-
(* Check timezone is GMT (case-insensitive) *)
-
if String.lowercase_ascii tz <> "gmt" then None
-
else
-
match month_of_string mon with
-
| None -> None
-
| Some month ->
-
let year = normalize_year year in
-
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
-
with _ -> None
-
-
(** Parse HTTP date by trying all supported formats in sequence *)
-
let parse_http_date s =
-
match parse_fmt1 s with
-
| Some t -> Some t
-
| None -> (
-
match parse_fmt2 s with
-
| Some t -> Some t
-
| None -> (
-
match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
-
end
-
-
(** {1 Cookie Parsing} *)
-
-
type cookie_attributes = {
-
mutable domain : string option;
-
mutable path : string option;
-
mutable secure : bool;
-
mutable http_only : bool;
-
mutable partitioned : bool;
-
mutable expires : Expiration.t option;
-
mutable max_age : Ptime.Span.t option;
-
mutable same_site : SameSite.t option;
-
}
-
(** Accumulated attributes from parsing Set-Cookie header *)
-
-
(** Create empty attribute accumulator *)
-
let empty_attributes () =
-
{
-
domain = None;
-
path = None;
-
secure = false;
-
http_only = false;
-
partitioned = false;
-
expires = None;
-
max_age = None;
-
same_site = None;
-
}
-
-
(** 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)
-
| "path" -> attrs.path <- Some attr_value
-
| "expires" -> (
-
(* Special case: Expires=0 means session cookie *)
-
if attr_value = "0" then attrs.expires <- Some `Session
-
else
-
match Ptime.of_rfc3339 attr_value with
-
| Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
-
| Error (`RFC3339 (_, err)) -> (
-
(* Try HTTP date format as fallback *)
-
match DateParser.parse_http_date attr_value with
-
| Some time -> attrs.expires <- Some (`DateTime time)
-
| None ->
-
Log.warn (fun m ->
-
m "Failed to parse expires attribute '%s': %a" attr_value
-
Ptime.pp_rfc3339_error err)))
-
| "max-age" -> (
-
match int_of_string_opt attr_value with
-
| 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 -> ());
-
Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
-
| None ->
-
Log.warn (fun m ->
-
m "Failed to parse max-age attribute '%s'" attr_value))
-
| "secure" -> attrs.secure <- true
-
| "httponly" -> attrs.http_only <- true
-
| "partitioned" -> attrs.partitioned <- true
-
| "samesite" -> (
-
match String.lowercase_ascii attr_value with
-
| "strict" -> attrs.same_site <- Some `Strict
-
| "lax" -> attrs.same_site <- Some `Lax
-
| "none" -> attrs.same_site <- Some `None
-
| _ ->
-
Log.warn (fun m ->
-
m "Invalid samesite value '%s', ignoring" attr_value))
-
| _ ->
-
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
-
-
(** Validate cookie attributes and log warnings for invalid combinations *)
-
let validate_attributes attrs =
-
(* SameSite=None requires Secure flag *)
-
let samesite_valid =
-
match attrs.same_site with
-
| Some `None when not attrs.secure ->
-
Log.warn (fun m ->
-
m
-
"Cookie has SameSite=None but Secure flag is not set; this \
-
violates RFC requirements");
-
false
-
| _ -> true
-
in
-
(* Partitioned requires Secure flag *)
-
let partitioned_valid =
-
if attrs.partitioned && not attrs.secure then (
-
Log.warn (fun m ->
-
m
-
"Cookie has Partitioned attribute but Secure flag is not set; \
-
this violates CHIPS requirements");
-
false)
-
else true
-
in
-
samesite_valid && partitioned_valid
-
-
(** Build final cookie from name/value and accumulated attributes *)
-
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
-
let domain =
-
normalize_domain (Option.value attrs.domain ~default: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 ()
-
-
let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
-
header_value =
-
Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
-
-
(* Split into attributes *)
-
let parts = String.split_on_char ';' header_value |> List.map String.trim in
-
-
match parts with
-
| [] -> None
-
| name_value :: attrs -> (
-
(* Parse name=value *)
-
match String.index_opt name_value '=' with
-
| None -> None
-
| Some eq_pos ->
-
let name = String.sub name_value 0 eq_pos |> String.trim in
-
let cookie_value =
-
String.sub name_value (eq_pos + 1)
-
(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
-
-
(* Parse all attributes into mutable accumulator *)
-
let accumulated_attrs = empty_attributes () in
-
List.iter
-
(fun attr ->
-
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 *)
-
if not (validate_attributes accumulated_attrs) then (
-
Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
-
None)
-
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)
-
-
and of_cookie_header ~clock ~domain ~path header_value =
-
Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
-
-
(* Split on semicolons *)
-
let parts = String.split_on_char ';' header_value |> List.map String.trim in
-
-
(* Filter out empty parts *)
-
let parts = List.filter (fun s -> String.length s > 0) parts in
-
-
(* Parse each name=value pair *)
-
List.map
-
(fun name_value ->
-
match String.index_opt name_value '=' with
-
| None ->
-
Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
-
| Some eq_pos ->
-
let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
-
if String.length cookie_name = 0 then
-
Error "Cookie has empty name"
-
else
-
let cookie_value =
-
String.sub name_value (eq_pos + 1)
-
(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
-
-
and make_cookie_header cookies =
-
cookies
-
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
-
|> String.concat "; "
-
-
and make_set_cookie_header cookie =
-
let buffer = Buffer.create 128 in
-
Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
-
-
(* Add Max-Age if present *)
-
(match max_age cookie with
-
| Some span -> (
-
match Ptime.Span.to_int_s span with
-
| Some seconds ->
-
Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
-
| None -> ())
-
| None -> ());
-
-
(* Add Expires if present *)
-
(match expires cookie with
-
| Some `Session ->
-
(* Session cookies can be indicated with Expires=0 or a past date *)
-
Buffer.add_string buffer "; Expires=0"
-
| Some (`DateTime exp_time) ->
-
(* Format as HTTP date *)
-
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
-
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
-
| None -> ());
-
-
(* Add Domain *)
-
Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
-
-
(* Add Path *)
-
Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie));
-
-
(* Add Secure flag *)
-
if secure cookie then Buffer.add_string buffer "; Secure";
-
-
(* Add HttpOnly flag *)
-
if http_only cookie then Buffer.add_string buffer "; HttpOnly";
-
-
(* Add Partitioned flag *)
-
if partitioned cookie then Buffer.add_string buffer "; Partitioned";
-
-
(* Add SameSite *)
-
(match same_site cookie with
-
| Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
-
| Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
-
| Some `None -> Buffer.add_string buffer "; SameSite=None"
-
| None -> ());
-
-
Buffer.contents buffer
-
-
(** {1 Pretty Printing} *)
-
-
and 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 }@]"
-
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
-
(http_only cookie) (partitioned cookie)
-
(Format.pp_print_option Expiration.pp)
-
(expires cookie)
-
(Format.pp_print_option Ptime.Span.pp)
-
(max_age cookie)
-
(Format.pp_print_option SameSite.pp)
-
(same_site cookie)
-
-
let pp_jar ppf jar =
-
Eio.Mutex.lock jar.mutex;
-
let original = jar.original_cookies in
-
let delta = jar.delta_cookies in
-
Eio.Mutex.unlock jar.mutex;
-
-
let all_cookies = original @ delta in
-
Format.fprintf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@,"
-
(List.length all_cookies) (List.length original) (List.length delta);
-
List.iter (fun cookie -> Format.fprintf ppf " %a@," pp cookie) all_cookies;
-
Format.fprintf ppf "@]"
-
-
(** {1 Cookie Management} *)
-
-
let add_cookie jar cookie =
-
Log.debug (fun m ->
-
m "Adding cookie to delta: %s=%s for domain %s" (name cookie)
-
(value cookie) (domain cookie));
-
-
Eio.Mutex.lock jar.mutex;
-
(* Remove existing cookie with same identity from delta *)
-
jar.delta_cookies <-
-
List.filter
-
(fun c -> not (cookie_identity_matches c cookie))
-
jar.delta_cookies;
-
jar.delta_cookies <- cookie :: jar.delta_cookies;
-
Eio.Mutex.unlock jar.mutex
-
-
let add_original jar cookie =
-
Log.debug (fun m ->
-
m "Adding original cookie: %s=%s for domain %s" (name cookie)
-
(value cookie) (domain cookie));
-
-
Eio.Mutex.lock jar.mutex;
-
(* Remove existing cookie with same identity from original *)
-
jar.original_cookies <-
-
List.filter
-
(fun c -> not (cookie_identity_matches c cookie))
-
jar.original_cookies;
-
jar.original_cookies <- cookie :: jar.original_cookies;
-
Eio.Mutex.unlock jar.mutex
-
-
let delta jar =
-
Eio.Mutex.lock jar.mutex;
-
let result = jar.delta_cookies in
-
Eio.Mutex.unlock jar.mutex;
-
Log.debug (fun m -> m "Returning %d delta cookies" (List.length result));
-
result
-
-
let make_removal_cookie cookie ~clock =
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
-
in
-
(* Create a cookie with Max-Age=0 and past expiration (1 year ago) *)
-
let past_expiry =
-
Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60))
-
|> Option.value ~default:Ptime.epoch
-
in
-
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) ~value:""
-
~secure:(secure cookie) ~http_only:(http_only cookie)
-
~expires:(`DateTime past_expiry) ~max_age:(Ptime.Span.of_int_s 0)
-
?same_site:(same_site cookie) ~partitioned:(partitioned cookie)
-
~creation_time:now ~last_access:now ()
-
-
let remove jar ~clock cookie =
-
Log.debug (fun m ->
-
m "Removing cookie: %s=%s for domain %s" (name cookie) (value cookie)
-
(domain cookie));
-
-
Eio.Mutex.lock jar.mutex;
-
(* Check if this cookie exists in original_cookies *)
-
let in_original =
-
List.exists (fun c -> cookie_identity_matches c cookie) jar.original_cookies
-
in
-
-
if in_original then (
-
(* Create a removal cookie and add it to delta *)
-
let removal = make_removal_cookie cookie ~clock in
-
jar.delta_cookies <-
-
List.filter
-
(fun c -> not (cookie_identity_matches c removal))
-
jar.delta_cookies;
-
jar.delta_cookies <- removal :: jar.delta_cookies;
-
Log.debug (fun m -> m "Created removal cookie in delta for original cookie"))
-
else (
-
(* Just remove from delta if it exists there *)
-
jar.delta_cookies <-
-
List.filter
-
(fun c -> not (cookie_identity_matches c cookie))
-
jar.delta_cookies;
-
Log.debug (fun m -> m "Removed cookie from delta"));
-
-
Eio.Mutex.unlock jar.mutex
-
-
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
-
=
-
Log.debug (fun m ->
-
m "Getting cookies for domain=%s path=%s secure=%b" request_domain
-
request_path is_secure);
-
-
Eio.Mutex.lock jar.mutex;
-
-
(* Combine original and delta cookies, with delta taking precedence *)
-
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
-
(* Filter out duplicates, keeping the last occurrence (from delta) *)
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
(* Keep this cookie only if no later cookie has the same identity *)
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique_cookies = dedup [] all_cookies in
-
-
(* Filter for applicable cookies, excluding removal cookies (empty value) *)
-
let applicable =
-
List.filter
-
(fun cookie ->
-
value cookie <> ""
-
(* Exclude removal cookies *)
-
&& domain_matches (domain cookie) request_domain
-
&& path_matches (path cookie) request_path
-
&& ((not (secure cookie)) || is_secure))
-
unique_cookies
-
in
-
-
(* Update last access time in both lists *)
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
-
in
-
let update_last_access cookies =
-
List.map
-
(fun c ->
-
if List.exists (fun a -> cookie_identity_matches a c) applicable then
-
make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c)
-
~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c)
-
?max_age:(max_age c) ?same_site:(same_site c)
-
~creation_time:(creation_time c) ~last_access:now ()
-
else c)
-
cookies
-
in
-
jar.original_cookies <- update_last_access jar.original_cookies;
-
jar.delta_cookies <- update_last_access jar.delta_cookies;
-
-
Eio.Mutex.unlock jar.mutex;
-
-
Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
-
applicable
-
-
let clear jar =
-
Log.info (fun m -> m "Clearing all cookies");
-
Eio.Mutex.lock jar.mutex;
-
jar.original_cookies <- [];
-
jar.delta_cookies <- [];
-
Eio.Mutex.unlock jar.mutex
-
-
let clear_expired jar ~clock =
-
Eio.Mutex.lock jar.mutex;
-
let before_count =
-
List.length jar.original_cookies + List.length jar.delta_cookies
-
in
-
jar.original_cookies <-
-
List.filter (fun c -> not (is_expired c clock)) jar.original_cookies;
-
jar.delta_cookies <-
-
List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies;
-
let removed =
-
before_count
-
- (List.length jar.original_cookies + List.length jar.delta_cookies)
-
in
-
Eio.Mutex.unlock jar.mutex;
-
Log.info (fun m -> m "Cleared %d expired cookies" removed)
-
-
let clear_session_cookies jar =
-
Eio.Mutex.lock jar.mutex;
-
let before_count =
-
List.length jar.original_cookies + List.length jar.delta_cookies
-
in
-
(* Keep only cookies that are NOT session cookies *)
-
let is_not_session c =
-
match expires c with
-
| Some `Session -> false (* This is a session cookie, remove it *)
-
| None | Some (`DateTime _) -> true (* Keep these *)
-
in
-
jar.original_cookies <- List.filter is_not_session jar.original_cookies;
-
jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
-
let removed =
-
before_count
-
- (List.length jar.original_cookies + List.length jar.delta_cookies)
-
in
-
Eio.Mutex.unlock jar.mutex;
-
Log.info (fun m -> m "Cleared %d session cookies" removed)
-
-
let count jar =
-
Eio.Mutex.lock jar.mutex;
-
(* Combine and deduplicate cookies for count *)
-
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
-
let n = List.length unique in
-
Eio.Mutex.unlock jar.mutex;
-
n
-
-
let get_all_cookies jar =
-
Eio.Mutex.lock jar.mutex;
-
(* Combine and deduplicate, with delta taking precedence *)
-
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
-
Eio.Mutex.unlock jar.mutex;
-
unique
-
-
let is_empty jar =
-
Eio.Mutex.lock jar.mutex;
-
let empty = jar.original_cookies = [] && jar.delta_cookies = [] in
-
Eio.Mutex.unlock jar.mutex;
-
empty
-
-
(** {1 Mozilla Format} *)
-
-
let to_mozilla_format_internal jar =
-
let buffer = Buffer.create 1024 in
-
Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
-
Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
-
-
(* Combine and deduplicate cookies *)
-
let all_cookies = jar.original_cookies @ jar.delta_cookies in
-
let rec dedup acc = function
-
| [] -> List.rev acc
-
| c :: rest ->
-
let has_duplicate =
-
List.exists (fun c2 -> cookie_identity_matches c c2) rest
-
in
-
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
-
in
-
let unique = dedup [] all_cookies in
-
-
List.iter
-
(fun cookie ->
-
let include_subdomains =
-
if String.starts_with ~prefix:"." (domain cookie) then "TRUE"
-
else "FALSE"
-
in
-
let secure_flag = if secure cookie then "TRUE" else "FALSE" in
-
let expires_str =
-
match expires cookie with
-
| None -> "0" (* No expiration *)
-
| Some `Session -> "0" (* Session cookie *)
-
| Some (`DateTime t) ->
-
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
-
epoch
-
in
-
-
Buffer.add_string buffer
-
(Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (domain cookie)
-
include_subdomains (path cookie) secure_flag expires_str
-
(name cookie) (value cookie)))
-
unique;
-
-
Buffer.contents buffer
-
-
let to_mozilla_format jar =
-
Eio.Mutex.lock jar.mutex;
-
let result = to_mozilla_format_internal jar in
-
Eio.Mutex.unlock jar.mutex;
-
result
-
-
let from_mozilla_format ~clock content =
-
Log.debug (fun m -> m "Parsing Mozilla format cookies");
-
let jar = create () in
-
-
let lines = String.split_on_char '\n' content in
-
List.iter
-
(fun line ->
-
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 ] ->
-
let now =
-
Ptime.of_float_s (Eio.Time.now clock)
-
|> Option.value ~default:Ptime.epoch
-
in
-
let expires =
-
let exp_int = try int_of_string expires with _ -> 0 in
-
if exp_int = 0 then None
-
else
-
match Ptime.of_float_s (float_of_int exp_int) with
-
| Some t -> Some (`DateTime t)
-
| None -> None
-
in
-
-
let cookie =
-
make ~domain:(normalize_domain domain) ~path ~name ~value
-
~secure:(secure = "TRUE") ~http_only:false ?expires ?max_age:None
-
?same_site:None ~partitioned:false ~creation_time:now
-
~last_access:now ()
-
in
-
add_original jar cookie;
-
Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
-
| _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line))
-
lines;
-
-
Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies));
-
jar
-
-
(** {1 File Operations} *)
-
-
let load ~clock path =
-
Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
-
-
try
-
let content = Eio.Path.load path in
-
from_mozilla_format ~clock content
-
with
-
| Eio.Io _ ->
-
Log.info (fun m -> m "Cookie file not found, creating empty jar");
-
create ()
-
| exn ->
-
Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
-
create ()
-
-
let save path jar =
-
Eio.Mutex.lock jar.mutex;
-
let total_cookies =
-
List.length jar.original_cookies + List.length jar.delta_cookies
-
in
-
Eio.Mutex.unlock jar.mutex;
-
Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path);
-
-
let content = to_mozilla_format jar in
-
-
try
-
Eio.Path.save ~create:(`Or_truncate 0o600) path content;
-
Log.debug (fun m -> m "Cookies saved successfully")
-
with exn ->
-
Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
···
+9 -108
lib/cookeio.mli lib/core/cookeio.mli
···
its scope, security, and lifetime. Cookies with the same [name], [domain],
and [path] will overwrite each other when added to a cookie jar. *)
-
type jar
-
(** Cookie jar for storing and managing cookies.
-
-
A cookie jar maintains a collection of cookies with automatic cleanup of
-
expired entries and enforcement of storage limits. It implements the
-
standard browser behavior for cookie storage, including:
-
- Automatic removal of expired cookies
-
- LRU eviction when storage limits are exceeded
-
- Domain and path-based cookie retrieval
-
- Mozilla format persistence for cross-tool compatibility *)
-
(** {1 Cookie Accessors} *)
val domain : t -> string
···
Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
combinations will result in validation errors. *)
-
(** {1 Cookie Jar Creation and Loading} *)
-
-
val create : unit -> jar
-
(** Create an empty cookie jar *)
-
-
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> jar
-
(** Load cookies from Mozilla format file.
-
-
Loads cookies from a file in Mozilla format, using the provided clock to set
-
creation and last access times. Returns an empty jar if the file doesn't
-
exist or cannot be loaded. *)
-
-
val save : Eio.Fs.dir_ty Eio.Path.t -> jar -> unit
-
(** Save cookies to Mozilla format file *)
-
-
(** {1 Cookie Jar Management} *)
-
-
val add_cookie : jar -> t -> unit
-
(** Add a cookie to the jar.
-
-
The cookie is added to the delta, meaning it will appear in Set-Cookie
-
headers when calling {!delta}. If a cookie with the same name/domain/path
-
exists in the delta, it will be replaced. *)
-
-
val add_original : jar -> t -> unit
-
(** Add an original cookie to the jar.
-
-
Original cookies are those received from the client (via Cookie header).
-
They do not appear in the delta. This method should be used when loading
-
cookies from incoming HTTP requests. *)
-
-
val delta : jar -> t list
-
(** Get cookies that need to be sent in Set-Cookie headers.
-
-
Returns cookies that have been added via {!add_cookie} and removal cookies
-
for original cookies that have been removed. Does not include original
-
cookies that were added via {!add_original}. *)
-
-
val remove : jar -> clock:_ Eio.Time.clock -> t -> unit
-
(** Remove a cookie from the jar.
-
-
If an original cookie with the same name/domain/path exists, creates a
-
removal cookie (empty value, Max-Age=0, past expiration) that appears in the
-
delta. If only a delta cookie exists, simply removes it from the delta. *)
-
-
val get_cookies :
-
jar ->
-
clock:_ Eio.Time.clock ->
-
domain:string ->
-
path:string ->
-
is_secure:bool ->
-
t list
-
(** Get cookies applicable for a URL.
-
-
Returns all cookies that match the given domain and path, and satisfy the
-
secure flag requirement. Combines original and delta cookies, with delta
-
taking precedence. Excludes removal cookies (empty value). Also updates the
-
last access time of matching cookies using the provided clock. *)
-
-
val clear : jar -> unit
-
(** Clear all cookies *)
-
-
val clear_expired : jar -> clock:_ Eio.Time.clock -> unit
-
(** Clear expired cookies *)
-
-
val clear_session_cookies : jar -> unit
-
(** Clear session cookies (those without expiry) *)
-
-
val count : jar -> int
-
(** Get the number of cookies in the jar *)
-
-
val get_all_cookies : jar -> t list
-
(** Get all cookies in the jar *)
-
-
val is_empty : jar -> bool
-
(** Check if the jar is empty *)
-
(** {1 Cookie Creation and Parsing} *)
-
val parse_set_cookie :
clock:_ Eio.Time.clock -> domain:string -> path:string -> string -> t option
-
(** Parse Set-Cookie header value into a cookie.
Parses a Set-Cookie header value following RFC specifications:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
···
- [Partitioned] requires the [Secure] flag to be set
Example:
-
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/" "session=abc123;
Secure; HttpOnly"] *)
val of_cookie_header :
···
path:string ->
string ->
(t, string) result list
-
(** Parse Cookie header containing semicolon-separated name=value pairs.
-
Cookie headers (client→server) contain only name=value pairs without
-
attributes: ["name1=value1; name2=value2; name3=value3"]
Creates cookies with:
- Provided [domain] and [path] from request context
···
val pp : Format.formatter -> t -> unit
(** Pretty print a cookie *)
-
-
val pp_jar : Format.formatter -> jar -> unit
-
(** Pretty print a cookie jar *)
-
-
(** {1 Mozilla Format} *)
-
-
val to_mozilla_format : jar -> string
-
(** Write cookies in Mozilla format *)
-
-
val from_mozilla_format : clock:_ Eio.Time.clock -> string -> jar
-
(** Parse Mozilla format cookies.
-
-
Creates a cookie jar from a string in Mozilla cookie format, using the
-
provided clock to set creation and last access times. *)
···
its scope, security, and lifetime. Cookies with the same [name], [domain],
and [path] will overwrite each other when added to a cookie jar. *)
(** {1 Cookie Accessors} *)
val domain : t -> string
···
Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
combinations will result in validation errors. *)
(** {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
+
name, value, and all attributes.
Parses a Set-Cookie header value following RFC specifications:
- Basic format: [NAME=VALUE; attribute1; attribute2=value2]
···
- [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 :
···
path:string ->
string ->
(t, string) result list
+
(** Parse Cookie request header containing semicolon-separated name=value pairs.
+
Cookie headers are sent from client to server and contain only name=value
+
pairs without attributes: ["name1=value1; name2=value2; name3=value3"]
Creates cookies with:
- Provided [domain] and [path] from request context
···
val pp : Format.formatter -> t -> unit
(** Pretty print a cookie *)
+470
lib/core/cookeio.ml
···
···
+
let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
+
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
module SameSite = struct
+
type t = [ `Strict | `Lax | `None ]
+
+
let equal = ( = )
+
+
let pp ppf = function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None"
+
end
+
+
module Expiration = struct
+
type t = [ `Session | `DateTime of Ptime.t ]
+
+
let equal e1 e2 =
+
match (e1, e2) with
+
| `Session, `Session -> true
+
| `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
+
| _ -> false
+
+
let pp ppf = function
+
| `Session -> Format.pp_print_string ppf "Session"
+
| `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
+
end
+
+
type t = {
+
domain : string;
+
path : string;
+
name : string;
+
value : string;
+
secure : bool;
+
http_only : bool;
+
partitioned : bool;
+
expires : Expiration.t option;
+
max_age : Ptime.Span.t option;
+
same_site : SameSite.t option;
+
creation_time : Ptime.t;
+
last_access : Ptime.t;
+
}
+
(** HTTP Cookie *)
+
+
(** {1 Cookie Accessors} *)
+
+
let domain cookie = cookie.domain
+
let path cookie = cookie.path
+
let name cookie = cookie.name
+
let value cookie = cookie.value
+
+
let value_trimmed cookie =
+
let v = cookie.value in
+
let len = String.length v in
+
if len < 2 then v
+
else
+
match (v.[0], v.[len - 1]) with
+
| '"', '"' -> String.sub v 1 (len - 2)
+
| _ -> v
+
+
let secure cookie = cookie.secure
+
let http_only cookie = cookie.http_only
+
let partitioned cookie = cookie.partitioned
+
let expires cookie = cookie.expires
+
let max_age cookie = cookie.max_age
+
let same_site cookie = cookie.same_site
+
let creation_time cookie = cookie.creation_time
+
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 () =
+
{
+
domain;
+
path;
+
name;
+
value;
+
secure;
+
http_only;
+
partitioned;
+
expires;
+
max_age;
+
same_site;
+
creation_time;
+
last_access;
+
}
+
+
(** {1 Cookie Parsing Helpers} *)
+
+
let normalize_domain domain =
+
(* Strip leading dot per RFC 6265 *)
+
match String.starts_with ~prefix:"." domain with
+
| true when String.length domain > 1 ->
+
String.sub domain 1 (String.length domain - 1)
+
| _ -> domain
+
+
(** {1 HTTP Date Parsing} *)
+
+
module DateParser = struct
+
(** Month name to number mapping (case-insensitive) *)
+
let month_of_string s =
+
match String.lowercase_ascii s with
+
| "jan" -> Some 1
+
| "feb" -> Some 2
+
| "mar" -> Some 3
+
| "apr" -> Some 4
+
| "may" -> Some 5
+
| "jun" -> Some 6
+
| "jul" -> Some 7
+
| "aug" -> Some 8
+
| "sep" -> Some 9
+
| "oct" -> Some 10
+
| "nov" -> Some 11
+
| "dec" -> Some 12
+
| _ -> None
+
+
(** Normalize abbreviated years:
+
- Years 69-99 get 1900 added (e.g., 95 → 1995)
+
- Years 0-68 get 2000 added (e.g., 25 → 2025)
+
- Years >= 100 are returned as-is *)
+
let normalize_year year =
+
if year >= 0 && year <= 68 then year + 2000
+
else if year >= 69 && year <= 99 then year + 1900
+
else year
+
+
(** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
+
let parse_fmt1 s =
+
try
+
Scanf.sscanf s "%s %d %s %d %d:%d:%d %s"
+
(fun _wday day mon year hour min sec tz ->
+
(* Check timezone is GMT (case-insensitive) *)
+
if String.lowercase_ascii tz <> "gmt" then None
+
else
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *)
+
let parse_fmt2 s =
+
try
+
Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s"
+
(fun _wday day mon year hour min sec tz ->
+
(* Check timezone is GMT (case-insensitive) *)
+
if String.lowercase_ascii tz <> "gmt" then None
+
else
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
+
let parse_fmt3 s =
+
try
+
Scanf.sscanf s "%s %s %d %d:%d:%d %d"
+
(fun _wday mon day hour min sec year ->
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
+
let parse_fmt4 s =
+
try
+
Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s"
+
(fun _wday day mon year hour min sec tz ->
+
(* Check timezone is GMT (case-insensitive) *)
+
if String.lowercase_ascii tz <> "gmt" then None
+
else
+
match month_of_string mon with
+
| None -> None
+
| Some month ->
+
let year = normalize_year year in
+
Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
+
with _ -> None
+
+
(** Parse HTTP date by trying all supported formats in sequence *)
+
let parse_http_date s =
+
match parse_fmt1 s with
+
| Some t -> Some t
+
| None -> (
+
match parse_fmt2 s with
+
| Some t -> Some t
+
| None -> (
+
match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
+
end
+
+
(** {1 Cookie Parsing} *)
+
+
type cookie_attributes = {
+
mutable domain : string option;
+
mutable path : string option;
+
mutable secure : bool;
+
mutable http_only : bool;
+
mutable partitioned : bool;
+
mutable expires : Expiration.t option;
+
mutable max_age : Ptime.Span.t option;
+
mutable same_site : SameSite.t option;
+
}
+
(** Accumulated attributes from parsing Set-Cookie header *)
+
+
(** Create empty attribute accumulator *)
+
let empty_attributes () =
+
{
+
domain = None;
+
path = None;
+
secure = false;
+
http_only = false;
+
partitioned = false;
+
expires = None;
+
max_age = None;
+
same_site = None;
+
}
+
+
(** 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)
+
| "path" -> attrs.path <- Some attr_value
+
| "expires" -> (
+
(* Special case: Expires=0 means session cookie *)
+
if attr_value = "0" then attrs.expires <- Some `Session
+
else
+
match Ptime.of_rfc3339 attr_value with
+
| Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
+
| Error (`RFC3339 (_, err)) -> (
+
(* Try HTTP date format as fallback *)
+
match DateParser.parse_http_date attr_value with
+
| Some time -> attrs.expires <- Some (`DateTime time)
+
| None ->
+
Log.warn (fun m ->
+
m "Failed to parse expires attribute '%s': %a" attr_value
+
Ptime.pp_rfc3339_error err)))
+
| "max-age" -> (
+
match int_of_string_opt attr_value with
+
| 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 -> ());
+
Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
+
| None ->
+
Log.warn (fun m ->
+
m "Failed to parse max-age attribute '%s'" attr_value))
+
| "secure" -> attrs.secure <- true
+
| "httponly" -> attrs.http_only <- true
+
| "partitioned" -> attrs.partitioned <- true
+
| "samesite" -> (
+
match String.lowercase_ascii attr_value with
+
| "strict" -> attrs.same_site <- Some `Strict
+
| "lax" -> attrs.same_site <- Some `Lax
+
| "none" -> attrs.same_site <- Some `None
+
| _ ->
+
Log.warn (fun m ->
+
m "Invalid samesite value '%s', ignoring" attr_value))
+
| _ ->
+
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
+
+
(** Validate cookie attributes and log warnings for invalid combinations *)
+
let validate_attributes attrs =
+
(* SameSite=None requires Secure flag *)
+
let samesite_valid =
+
match attrs.same_site with
+
| Some `None when not attrs.secure ->
+
Log.warn (fun m ->
+
m
+
"Cookie has SameSite=None but Secure flag is not set; this \
+
violates RFC requirements");
+
false
+
| _ -> true
+
in
+
(* Partitioned requires Secure flag *)
+
let partitioned_valid =
+
if attrs.partitioned && not attrs.secure then (
+
Log.warn (fun m ->
+
m
+
"Cookie has Partitioned attribute but Secure flag is not set; \
+
this violates CHIPS requirements");
+
false)
+
else true
+
in
+
samesite_valid && partitioned_valid
+
+
(** Build final cookie from name/value and accumulated attributes *)
+
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
+
let domain =
+
normalize_domain (Option.value attrs.domain ~default: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 ()
+
+
(** {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 }@]"
+
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
+
(http_only cookie) (partitioned cookie)
+
(Format.pp_print_option Expiration.pp)
+
(expires cookie)
+
(Format.pp_print_option Ptime.Span.pp)
+
(max_age cookie)
+
(Format.pp_print_option SameSite.pp)
+
(same_site cookie)
+
+
(** {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);
+
+
(* Split into attributes *)
+
let parts = String.split_on_char ';' header_value |> List.map String.trim in
+
+
match parts with
+
| [] -> None
+
| name_value :: attrs -> (
+
(* Parse name=value *)
+
match String.index_opt name_value '=' with
+
| None -> None
+
| Some eq_pos ->
+
let name = String.sub name_value 0 eq_pos |> String.trim in
+
let cookie_value =
+
String.sub name_value (eq_pos + 1)
+
(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
+
+
(* Parse all attributes into mutable accumulator *)
+
let accumulated_attrs = empty_attributes () in
+
List.iter
+
(fun attr ->
+
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 *)
+
if not (validate_attributes accumulated_attrs) then (
+
Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
+
None)
+
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 *)
+
let parts = String.split_on_char ';' header_value |> List.map String.trim in
+
+
(* Filter out empty parts *)
+
let parts = List.filter (fun s -> String.length s > 0) parts in
+
+
(* Parse each name=value pair *)
+
List.map
+
(fun name_value ->
+
match String.index_opt name_value '=' with
+
| None ->
+
Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
+
| Some eq_pos ->
+
let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
+
if String.length cookie_name = 0 then
+
Error "Cookie has empty name"
+
else
+
let cookie_value =
+
String.sub name_value (eq_pos + 1)
+
(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
+
+
let make_cookie_header cookies =
+
cookies
+
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
+
|> String.concat "; "
+
+
let make_set_cookie_header cookie =
+
let buffer = Buffer.create 128 in
+
Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
+
+
(* Add Max-Age if present *)
+
(match max_age cookie with
+
| Some span -> (
+
match Ptime.Span.to_int_s span with
+
| Some seconds ->
+
Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
+
| None -> ())
+
| None -> ());
+
+
(* Add Expires if present *)
+
(match expires cookie with
+
| Some `Session ->
+
(* Session cookies can be indicated with Expires=0 or a past date *)
+
Buffer.add_string buffer "; Expires=0"
+
| Some (`DateTime exp_time) ->
+
(* Format as HTTP date *)
+
let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
+
Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
+
| None -> ());
+
+
(* Add Domain *)
+
Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
+
+
(* Add Path *)
+
Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie));
+
+
(* Add Secure flag *)
+
if secure cookie then Buffer.add_string buffer "; Secure";
+
+
(* Add HttpOnly flag *)
+
if http_only cookie then Buffer.add_string buffer "; HttpOnly";
+
+
(* Add Partitioned flag *)
+
if partitioned cookie then Buffer.add_string buffer "; Partitioned";
+
+
(* Add SameSite *)
+
(match same_site cookie with
+
| Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
+
| Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
+
| Some `None -> Buffer.add_string buffer "; SameSite=None"
+
| None -> ());
+
+
Buffer.contents buffer
lib/dune lib/core/dune
+434
lib/jar/cookeio_jar.ml
···
···
+
let src = Logs.Src.create "cookie_jar" ~doc:"Cookie jar management"
+
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
type t = {
+
mutable original_cookies : Cookeio.t list; (* from client *)
+
mutable delta_cookies : Cookeio.t list; (* to send back *)
+
mutex : Eio.Mutex.t;
+
}
+
(** Cookie jar for storing and managing cookies *)
+
+
(** {1 Cookie Jar Creation} *)
+
+
let create () =
+
Log.debug (fun m -> m "Creating new empty cookie jar");
+
{ original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () }
+
+
(** {1 Cookie Matching Helpers} *)
+
+
let cookie_identity_matches c1 c2 =
+
Cookeio.name c1 = Cookeio.name c2
+
&& Cookeio.domain c1 = Cookeio.domain c2
+
&& Cookeio.path c1 = Cookeio.path c2
+
+
let normalize_domain domain =
+
(* Strip leading dot per RFC 6265 *)
+
match String.starts_with ~prefix:"." domain with
+
| true when String.length domain > 1 ->
+
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). *)
+
request_domain = cookie_domain
+
|| 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
+
+
(** {1 HTTP Date Parsing} *)
+
let is_expired cookie clock =
+
match Cookeio.expires cookie with
+
| None -> false (* No expiration *)
+
| Some `Session -> false (* Session cookie - not expired until browser closes *)
+
| Some (`DateTime exp_time) ->
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
+
in
+
Ptime.compare now exp_time > 0
+
+
let pp ppf jar =
+
Eio.Mutex.lock jar.mutex;
+
let original = jar.original_cookies in
+
let delta = jar.delta_cookies in
+
Eio.Mutex.unlock jar.mutex;
+
+
let all_cookies = original @ delta in
+
Format.fprintf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@,"
+
(List.length all_cookies) (List.length original) (List.length delta);
+
List.iter
+
(fun cookie -> Format.fprintf ppf " %a@," Cookeio.pp cookie)
+
all_cookies;
+
Format.fprintf ppf "@]"
+
+
(** {1 Cookie Management} *)
+
+
let add_cookie jar cookie =
+
Log.debug (fun m ->
+
m "Adding cookie to delta: %s=%s for domain %s"
+
(Cookeio.name cookie)
+
(Cookeio.value cookie)
+
(Cookeio.domain cookie));
+
+
Eio.Mutex.lock jar.mutex;
+
(* Remove existing cookie with same identity from delta *)
+
jar.delta_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.delta_cookies;
+
jar.delta_cookies <- cookie :: jar.delta_cookies;
+
Eio.Mutex.unlock jar.mutex
+
+
let add_original jar cookie =
+
Log.debug (fun m ->
+
m "Adding original cookie: %s=%s for domain %s"
+
(Cookeio.name cookie)
+
(Cookeio.value cookie)
+
(Cookeio.domain cookie));
+
+
Eio.Mutex.lock jar.mutex;
+
(* Remove existing cookie with same identity from original *)
+
jar.original_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.original_cookies;
+
jar.original_cookies <- cookie :: jar.original_cookies;
+
Eio.Mutex.unlock jar.mutex
+
+
let delta jar =
+
Eio.Mutex.lock jar.mutex;
+
let result = jar.delta_cookies in
+
Eio.Mutex.unlock jar.mutex;
+
Log.debug (fun m -> m "Returning %d delta cookies" (List.length result));
+
result
+
+
let make_removal_cookie cookie ~clock =
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
(* Create a cookie with Max-Age=0 and past expiration (1 year ago) *)
+
let past_expiry =
+
Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60))
+
|> Option.value ~default:Ptime.epoch
+
in
+
Cookeio.make
+
~domain:(Cookeio.domain cookie)
+
~path:(Cookeio.path cookie)
+
~name:(Cookeio.name cookie)
+
~value:""
+
~secure:(Cookeio.secure cookie)
+
~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)
+
~creation_time:now ~last_access:now ()
+
+
let remove jar ~clock cookie =
+
Log.debug (fun m ->
+
m "Removing cookie: %s=%s for domain %s"
+
(Cookeio.name cookie)
+
(Cookeio.value cookie)
+
(Cookeio.domain cookie));
+
+
Eio.Mutex.lock jar.mutex;
+
(* Check if this cookie exists in original_cookies *)
+
let in_original =
+
List.exists (fun c -> cookie_identity_matches c cookie) jar.original_cookies
+
in
+
+
if in_original then (
+
(* Create a removal cookie and add it to delta *)
+
let removal = make_removal_cookie cookie ~clock in
+
jar.delta_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c removal))
+
jar.delta_cookies;
+
jar.delta_cookies <- removal :: jar.delta_cookies;
+
Log.debug (fun m -> m "Created removal cookie in delta for original cookie"))
+
else (
+
(* Just remove from delta if it exists there *)
+
jar.delta_cookies <-
+
List.filter
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.delta_cookies;
+
Log.debug (fun m -> m "Removed cookie from delta"));
+
+
Eio.Mutex.unlock jar.mutex
+
+
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
+
=
+
Log.debug (fun m ->
+
m "Getting cookies for domain=%s path=%s secure=%b" request_domain
+
request_path is_secure);
+
+
Eio.Mutex.lock jar.mutex;
+
+
(* Combine original and delta cookies, with delta taking precedence *)
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
+
(* Filter out duplicates, keeping the last occurrence (from delta) *)
+
let rec dedup acc = function
+
| [] -> List.rev acc
+
| c :: rest ->
+
(* Keep this cookie only if no later cookie has the same identity *)
+
let has_duplicate =
+
List.exists (fun c2 -> cookie_identity_matches c c2) rest
+
in
+
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
+
in
+
let unique_cookies = dedup [] all_cookies in
+
+
(* Filter for applicable cookies, excluding removal cookies (empty value) *)
+
let applicable =
+
List.filter
+
(fun cookie ->
+
Cookeio.value cookie <> ""
+
(* Exclude removal cookies *)
+
&& domain_matches (Cookeio.domain cookie) request_domain
+
&& path_matches (Cookeio.path cookie) request_path
+
&& ((not (Cookeio.secure cookie)) || is_secure))
+
unique_cookies
+
in
+
+
(* Update last access time in both lists *)
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
+
in
+
let update_last_access cookies =
+
List.map
+
(fun c ->
+
if List.exists (fun a -> cookie_identity_matches a c) applicable then
+
Cookeio.make
+
~domain:(Cookeio.domain c)
+
~path:(Cookeio.path c)
+
~name:(Cookeio.name c)
+
~value:(Cookeio.value c)
+
~secure:(Cookeio.secure c)
+
~http_only:(Cookeio.http_only c)
+
?expires:(Cookeio.expires c)
+
?max_age:(Cookeio.max_age c)
+
?same_site:(Cookeio.same_site c)
+
~partitioned:(Cookeio.partitioned c)
+
~creation_time:(Cookeio.creation_time c)
+
~last_access:now ()
+
else c)
+
cookies
+
in
+
jar.original_cookies <- update_last_access jar.original_cookies;
+
jar.delta_cookies <- update_last_access jar.delta_cookies;
+
+
Eio.Mutex.unlock jar.mutex;
+
+
Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
+
applicable
+
+
let clear jar =
+
Log.info (fun m -> m "Clearing all cookies");
+
Eio.Mutex.lock jar.mutex;
+
jar.original_cookies <- [];
+
jar.delta_cookies <- [];
+
Eio.Mutex.unlock jar.mutex
+
+
let clear_expired jar ~clock =
+
Eio.Mutex.lock jar.mutex;
+
let before_count =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
jar.original_cookies <-
+
List.filter (fun c -> not (is_expired c clock)) jar.original_cookies;
+
jar.delta_cookies <-
+
List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies;
+
let removed =
+
before_count
+
- (List.length jar.original_cookies + List.length jar.delta_cookies)
+
in
+
Eio.Mutex.unlock jar.mutex;
+
Log.info (fun m -> m "Cleared %d expired cookies" removed)
+
+
let clear_session_cookies jar =
+
Eio.Mutex.lock jar.mutex;
+
let before_count =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
(* Keep only cookies that are NOT session cookies *)
+
let is_not_session c =
+
match Cookeio.expires c with
+
| Some `Session -> false (* This is a session cookie, remove it *)
+
| None | Some (`DateTime _) -> true (* Keep these *)
+
in
+
jar.original_cookies <- List.filter is_not_session jar.original_cookies;
+
jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
+
let removed =
+
before_count
+
- (List.length jar.original_cookies + List.length jar.delta_cookies)
+
in
+
Eio.Mutex.unlock jar.mutex;
+
Log.info (fun m -> m "Cleared %d session cookies" removed)
+
+
let count jar =
+
Eio.Mutex.lock jar.mutex;
+
(* Combine and deduplicate cookies for count *)
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
let rec dedup acc = function
+
| [] -> List.rev acc
+
| c :: rest ->
+
let has_duplicate =
+
List.exists (fun c2 -> cookie_identity_matches c c2) rest
+
in
+
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
+
in
+
let unique = dedup [] all_cookies in
+
let n = List.length unique in
+
Eio.Mutex.unlock jar.mutex;
+
n
+
+
let get_all_cookies jar =
+
Eio.Mutex.lock jar.mutex;
+
(* Combine and deduplicate, with delta taking precedence *)
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
let rec dedup acc = function
+
| [] -> List.rev acc
+
| c :: rest ->
+
let has_duplicate =
+
List.exists (fun c2 -> cookie_identity_matches c c2) rest
+
in
+
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
+
in
+
let unique = dedup [] all_cookies in
+
Eio.Mutex.unlock jar.mutex;
+
unique
+
+
let is_empty jar =
+
Eio.Mutex.lock jar.mutex;
+
let empty = jar.original_cookies = [] && jar.delta_cookies = [] in
+
Eio.Mutex.unlock jar.mutex;
+
empty
+
+
(** {1 Mozilla Format} *)
+
+
let to_mozilla_format_internal jar =
+
let buffer = Buffer.create 1024 in
+
Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
+
Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
+
+
(* Combine and deduplicate cookies *)
+
let all_cookies = jar.original_cookies @ jar.delta_cookies in
+
let rec dedup acc = function
+
| [] -> List.rev acc
+
| c :: rest ->
+
let has_duplicate =
+
List.exists (fun c2 -> cookie_identity_matches c c2) rest
+
in
+
if has_duplicate then dedup acc rest else dedup (c :: acc) rest
+
in
+
let unique = dedup [] all_cookies in
+
+
List.iter
+
(fun cookie ->
+
let include_subdomains =
+
if String.starts_with ~prefix:"." (Cookeio.domain cookie) then "TRUE"
+
else "FALSE"
+
in
+
let secure_flag = if Cookeio.secure cookie then "TRUE" else "FALSE" in
+
let expires_str =
+
match Cookeio.expires cookie with
+
| None -> "0" (* No expiration *)
+
| Some `Session -> "0" (* Session cookie *)
+
| Some (`DateTime t) ->
+
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
+
epoch
+
in
+
+
Buffer.add_string buffer
+
(Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
+
(Cookeio.domain cookie)
+
include_subdomains
+
(Cookeio.path cookie)
+
secure_flag expires_str
+
(Cookeio.name cookie)
+
(Cookeio.value cookie)))
+
unique;
+
+
Buffer.contents buffer
+
+
let to_mozilla_format jar =
+
Eio.Mutex.lock jar.mutex;
+
let result = to_mozilla_format_internal jar in
+
Eio.Mutex.unlock jar.mutex;
+
result
+
+
let from_mozilla_format ~clock content =
+
Log.debug (fun m -> m "Parsing Mozilla format cookies");
+
let jar = create () in
+
+
let lines = String.split_on_char '\n' content in
+
List.iter
+
(fun line ->
+
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 ] ->
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
+
in
+
let expires =
+
let exp_int = try int_of_string expires with _ -> 0 in
+
if exp_int = 0 then None
+
else
+
match Ptime.of_float_s (float_of_int exp_int) with
+
| Some t -> Some (`DateTime t)
+
| None -> None
+
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 ~creation_time:now
+
~last_access:now ()
+
in
+
add_original jar cookie;
+
Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
+
| _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line))
+
lines;
+
+
Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies));
+
jar
+
+
(** {1 File Operations} *)
+
+
let load ~clock path =
+
Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
+
+
try
+
let content = Eio.Path.load path in
+
from_mozilla_format ~clock content
+
with
+
| Eio.Io _ ->
+
Log.info (fun m -> m "Cookie file not found, creating empty jar");
+
create ()
+
| exn ->
+
Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
+
create ()
+
+
let save path jar =
+
Eio.Mutex.lock jar.mutex;
+
let total_cookies =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
Eio.Mutex.unlock jar.mutex;
+
Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path);
+
+
let content = to_mozilla_format jar in
+
+
try
+
Eio.Path.save ~create:(`Or_truncate 0o600) path content;
+
Log.debug (fun m -> m "Cookies saved successfully")
+
with exn ->
+
Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+117
lib/jar/cookeio_jar.mli
···
···
+
(** Cookie jar for storing and managing HTTP cookies.
+
+
This module provides a complete cookie jar implementation following
+
established web standards while integrating Eio for efficient asynchronous
+
operations.
+
+
A cookie jar maintains a collection of cookies with automatic cleanup of
+
expired entries. It implements the standard browser behavior for cookie
+
storage, including:
+
- Automatic removal of expired cookies
+
- Domain and path-based cookie retrieval
+
- Delta tracking for Set-Cookie headers
+
- Mozilla format persistence for cross-tool compatibility *)
+
+
type t
+
(** Cookie jar for storing and managing cookies.
+
+
A cookie jar maintains a collection of cookies with automatic cleanup of
+
expired entries and enforcement of storage limits. It implements the
+
standard browser behavior for cookie storage, including:
+
- Automatic removal of expired cookies
+
- LRU eviction when storage limits are exceeded
+
- Domain and path-based cookie retrieval
+
- Mozilla format persistence for cross-tool compatibility *)
+
+
(** {1 Cookie Jar Creation and Loading} *)
+
+
val create : unit -> t
+
(** Create an empty cookie jar *)
+
+
val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t
+
(** Load cookies from Mozilla format file.
+
+
Loads cookies from a file in Mozilla format, using the provided clock to set
+
creation and last access times. Returns an empty jar if the file doesn't
+
exist or cannot be loaded. *)
+
+
val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit
+
(** Save cookies to Mozilla format file *)
+
+
(** {1 Cookie Jar Management} *)
+
+
val add_cookie : t -> Cookeio.t -> unit
+
(** Add a cookie to the jar.
+
+
The cookie is added to the delta, meaning it will appear in Set-Cookie
+
headers when calling {!delta}. If a cookie with the same name/domain/path
+
exists in the delta, it will be replaced. *)
+
+
val add_original : t -> Cookeio.t -> unit
+
(** Add an original cookie to the jar.
+
+
Original cookies are those received from the client (via Cookie header).
+
They do not appear in the delta. This method should be used when loading
+
cookies from incoming HTTP requests. *)
+
+
val delta : t -> Cookeio.t list
+
(** Get cookies that need to be sent in Set-Cookie headers.
+
+
Returns cookies that have been added via {!add_cookie} and removal cookies
+
for original cookies that have been removed. Does not include original
+
cookies that were added via {!add_original}. *)
+
+
val remove : t -> clock:_ Eio.Time.clock -> Cookeio.t -> unit
+
(** Remove a cookie from the jar.
+
+
If an original cookie with the same name/domain/path exists, creates a
+
removal cookie (empty value, Max-Age=0, past expiration) that appears in the
+
delta. If only a delta cookie exists, simply removes it from the delta. *)
+
+
val get_cookies :
+
t ->
+
clock:_ Eio.Time.clock ->
+
domain:string ->
+
path:string ->
+
is_secure:bool ->
+
Cookeio.t list
+
(** Get cookies applicable for a URL.
+
+
Returns all cookies that match the given domain and path, and satisfy the
+
secure flag requirement. Combines original and delta cookies, with delta
+
taking precedence. Excludes removal cookies (empty value). Also updates the
+
last access time of matching cookies using the provided clock. *)
+
+
val clear : t -> unit
+
(** Clear all cookies *)
+
+
val clear_expired : t -> clock:_ Eio.Time.clock -> unit
+
(** Clear expired cookies *)
+
+
val clear_session_cookies : t -> unit
+
(** Clear session cookies (those without expiry) *)
+
+
val count : t -> int
+
(** Get the number of cookies in the jar *)
+
+
val get_all_cookies : t -> Cookeio.t list
+
(** Get all cookies in the jar *)
+
+
val is_empty : t -> bool
+
(** Check if the jar is empty *)
+
+
(** {1 Pretty Printing} *)
+
+
val pp : Format.formatter -> t -> unit
+
(** Pretty print a cookie jar *)
+
+
(** {1 Mozilla Format} *)
+
+
val to_mozilla_format : t -> string
+
(** Write cookies in Mozilla format *)
+
+
val from_mozilla_format : clock:_ Eio.Time.clock -> string -> t
+
(** Parse Mozilla format cookies.
+
+
Creates a cookie jar from a string in Mozilla cookie format, using the
+
provided clock to set creation and last access times. *)
+4
lib/jar/dune
···
···
+
(library
+
(name cookeio_jar)
+
(public_name cookeio.jar)
+
(libraries cookeio eio logs ptime unix))
+1 -1
test/dune
···
(test
(name test_cookeio)
-
(libraries cookeio alcotest eio eio.unix eio_main eio.mock ptime str)
(deps cookies.txt))
···
(test
(name test_cookeio)
+
(libraries cookeio cookeio_jar alcotest eio eio.unix eio_main eio.mock ptime str)
(deps cookies.txt))
+44 -43
test/test_cookeio.ml
···
open Cookeio
(* Testable helpers for Priority 2 types *)
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
···
(* Parse a Set-Cookie header with Max-Age *)
let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in
let cookie_opt =
-
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
···
"updated last access" (Ptime.of_float_s 4000.0)
(Some (Cookeio.last_access cookie2))
-
let test_parse_set_cookie_with_expires () =
Eio_mock.Backend.run @@ fun () ->
let clock = Eio_mock.Clock.make () in
···
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
in
let cookie_opt =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~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 =
-
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
in
(* Cookie should still be parsed, just without expires *)
···
List.iter
(fun (header, description) ->
let cookie_opt =
-
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool)
(description ^ " parsed") true
···
List.iter
(fun (header, description) ->
let cookie_opt =
-
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
in
Alcotest.(check bool)
(description ^ " parsed") true
···
add_original jar cookie;
(* Delta should be empty *)
-
let delta = Cookeio.delta jar in
Alcotest.(check int) "delta is empty" 0 (List.length delta);
(* But the cookie should be in the jar *)
···
add_cookie jar cookie;
(* Delta should contain the cookie *)
-
let delta = Cookeio.delta jar in
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
let delta_cookie = List.hd delta in
Alcotest.(check string) "delta cookie name" "test" (Cookeio.name delta_cookie);
···
add_original jar cookie;
(* Remove the cookie *)
-
Cookeio.remove jar ~clock cookie;
(* Delta should contain a removal cookie *)
-
let delta = Cookeio.delta jar in
Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta);
let removal_cookie = List.hd delta in
Alcotest.(check string)
···
add_cookie jar cookie;
(* Remove the cookie *)
-
Cookeio.remove jar ~clock cookie;
(* Delta should be empty *)
-
let delta = Cookeio.delta jar in
Alcotest.(check int)
"delta is empty after removing delta cookie" 0 (List.length delta)
···
add_original jar original;
(* Remove it *)
-
Cookeio.remove jar ~clock original;
(* Get cookies should return nothing *)
let cookies =
···
Alcotest.(check int) "no cookies returned" 0 (List.length cookies);
(* But delta should have the removal cookie *)
-
let delta = Cookeio.delta jar in
Alcotest.(check int) "delta has removal cookie" 1 (List.length delta)
let test_delta_returns_only_changed_cookies () =
···
add_cookie jar new_cookie;
(* Delta should only contain the new cookie *)
-
let delta = Cookeio.delta jar in
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
let delta_cookie = List.hd delta in
Alcotest.(check string) "delta cookie name" "new" (Cookeio.name delta_cookie)
···
add_original jar cookie;
(* Remove the cookie *)
-
Cookeio.remove jar ~clock cookie;
(* Get the removal cookie *)
-
let delta = Cookeio.delta jar in
let removal = List.hd delta in
(* Check all properties *)
···
let test_partitioned_parsing env =
let clock = Eio.Stdenv.clock env in
-
match parse_set_cookie ~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 parse_set_cookie ~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 parse_set_cookie ~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 parse_set_cookie ~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 parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
"name=\"value\"" with
| Some c1 ->
-
begin match parse_set_cookie ~clock ~domain:"ex.com" ~path:"/"
"name=value" with
| Some c2 ->
(* Different raw values *)
···
let clock = Eio.Stdenv.clock env in
(* Parse Set-Cookie with both attributes *)
-
match parse_set_cookie ~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 *)
···
test_case "Last access time with mock clock" `Quick
test_last_access_time_with_mock_clock;
test_case "Parse Set-Cookie with Expires" `Quick
-
test_parse_set_cookie_with_expires;
test_case "SameSite=None validation" `Quick
test_samesite_none_validation;
] );
···
open Cookeio
+
open Cookeio_jar
(* Testable helpers for Priority 2 types *)
let expiration_testable : Cookeio.Expiration.t Alcotest.testable =
···
(* 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);
···
"updated last access" (Ptime.of_float_s 4000.0)
(Some (Cookeio.last_access cookie2))
+
let test_of_set_cookie_header_with_expires () =
Eio_mock.Backend.run @@ fun () ->
let clock = Eio_mock.Clock.make () in
···
"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
···
add_original jar cookie;
(* Delta should be empty *)
+
let delta = delta jar in
Alcotest.(check int) "delta is empty" 0 (List.length delta);
(* But the cookie should be in the jar *)
···
add_cookie jar cookie;
(* Delta should contain the cookie *)
+
let delta = delta jar in
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
let delta_cookie = List.hd delta in
Alcotest.(check string) "delta cookie name" "test" (Cookeio.name delta_cookie);
···
add_original jar cookie;
(* Remove the cookie *)
+
remove jar ~clock cookie;
(* Delta should contain a removal cookie *)
+
let delta = delta jar in
Alcotest.(check int) "delta has 1 removal cookie" 1 (List.length delta);
let removal_cookie = List.hd delta in
Alcotest.(check string)
···
add_cookie jar cookie;
(* Remove the cookie *)
+
remove jar ~clock cookie;
(* Delta should be empty *)
+
let delta = delta jar in
Alcotest.(check int)
"delta is empty after removing delta cookie" 0 (List.length delta)
···
add_original jar original;
(* Remove it *)
+
remove jar ~clock original;
(* Get cookies should return nothing *)
let cookies =
···
Alcotest.(check int) "no cookies returned" 0 (List.length cookies);
(* But delta should have the removal cookie *)
+
let delta = delta jar in
Alcotest.(check int) "delta has removal cookie" 1 (List.length delta)
let test_delta_returns_only_changed_cookies () =
···
add_cookie jar new_cookie;
(* Delta should only contain the new cookie *)
+
let delta = delta jar in
Alcotest.(check int) "delta has 1 cookie" 1 (List.length delta);
let delta_cookie = List.hd delta in
Alcotest.(check string) "delta cookie name" "new" (Cookeio.name delta_cookie)
···
add_original jar cookie;
(* Remove the cookie *)
+
remove jar ~clock cookie;
(* Get the removal cookie *)
+
let delta = delta jar in
let removal = List.hd delta in
(* Check all properties *)
···
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 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 *)
···
test_case "Last access time with mock clock" `Quick
test_last_access_time_with_mock_clock;
test_case "Parse Set-Cookie with Expires" `Quick
+
test_of_set_cookie_header_with_expires;
test_case "SameSite=None validation" `Quick
test_samesite_none_validation;
] );