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

more feature coverage of max-age and other date formats

Changed files
+1382 -130
lib
test
+1 -1
.ocamlformat
···
-
version=0.27.0
+
version=0.28.1
+400 -73
lib/cookeio.ml
···
secure : bool;
http_only : bool;
expires : Ptime.t option;
+
max_age : Ptime.Span.t option;
same_site : same_site option;
creation_time : Ptime.t;
last_access : Ptime.t;
}
(** HTTP Cookie *)
-
type jar = { mutable cookies : t list; mutex : Eio.Mutex.t }
+
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 secure cookie = cookie.secure
let http_only cookie = cookie.http_only
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 ?same_site ~creation_time ~last_access () =
-
{ domain; path; name; value; secure; http_only; expires; same_site; creation_time; last_access }
+
?expires ?max_age ?same_site ~creation_time ~last_access () =
+
{
+
domain;
+
path;
+
name;
+
value;
+
secure;
+
http_only;
+
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");
-
{ cookies = []; mutex = Eio.Mutex.create () }
+
{ 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 domain .example.com matches example.com and sub.example.com *)
-
if String.starts_with ~prefix:"." cookie_domain then
-
let domain_suffix = String.sub cookie_domain 1 (String.length cookie_domain - 1) in
-
request_domain = domain_suffix
-
|| String.ends_with ~suffix:("." ^ domain_suffix) request_domain
-
else 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 (* Session cookie *)
···
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} *)
-
(** Accumulated attributes from parsing Set-Cookie header *)
type cookie_attributes = {
mutable domain : string option;
mutable path : string option;
mutable secure : bool;
mutable http_only : bool;
mutable expires : Ptime.t option;
+
mutable max_age : Ptime.Span.t option;
mutable same_site : same_site option;
}
+
(** Accumulated attributes from parsing Set-Cookie header *)
(** Create empty attribute accumulator *)
let empty_attributes () =
···
secure = false;
http_only = false;
expires = None;
+
max_age = None;
same_site = None;
}
···
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 attr_value
+
| "domain" -> attrs.domain <- Some (normalize_domain attr_value)
| "path" -> attrs.path <- Some attr_value
| "expires" -> (
match Ptime.of_rfc3339 attr_value with
| Ok (time, _, _) -> attrs.expires <- Some time
-
| Error (`RFC3339 (_, err)) ->
-
Log.warn (fun m ->
-
m "Failed to parse expires attribute '%s': %a" attr_value
-
Ptime.pp_rfc3339_error err))
+
| Error (`RFC3339 (_, err)) -> (
+
(* Try HTTP date format as fallback *)
+
match DateParser.parse_http_date attr_value with
+
| Some time -> attrs.expires <- Some 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 *)
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
-
attrs.expires <- expires
+
attrs.expires <- expires;
+
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))
+
Log.warn (fun m ->
+
m "Failed to parse max-age attribute '%s'" attr_value))
| "secure" -> attrs.secure <- true
| "httponly" -> attrs.http_only <- true
| "samesite" -> (
···
| "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.warn (fun m ->
+
m "Invalid samesite value '%s', ignoring" attr_value))
| _ ->
Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
···
| 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");
+
"Cookie has SameSite=None but Secure flag is not set; this \
+
violates RFC requirements");
false
| _ -> true
(** Build final cookie from name/value and accumulated attributes *)
let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
-
let domain = Option.value attrs.domain ~default:request_domain in
+
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 ?same_site:attrs.same_site ~creation_time:now
-
~last_access:now ()
+
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 ~creation_time:now ~last_access:now ()
let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
header_value =
···
None)
else
let cookie =
-
build_cookie ~request_domain ~request_path ~name ~value:cookie_value
-
accumulated_attrs ~now
+
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)
···
|> 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 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 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_same_site ppf = function
···
and pp ppf cookie =
Format.fprintf ppf
"@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
-
http_only=%b;@ expires=%a;@ same_site=%a }@]"
+
http_only=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
(name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
(http_only cookie)
(Format.pp_print_option Ptime.pp)
(expires cookie)
+
(Format.pp_print_option Ptime.Span.pp)
+
(max_age cookie)
(Format.pp_print_option pp_same_site)
(same_site cookie)
let pp_jar ppf jar =
Eio.Mutex.lock jar.mutex;
-
let cookies = jar.cookies in
+
let original = jar.original_cookies in
+
let delta = jar.delta_cookies in
Eio.Mutex.unlock jar.mutex;
-
Format.fprintf ppf "@[<v>CookieJar with %d cookies:@," (List.length cookies);
-
List.iter (fun cookie -> Format.fprintf ppf " %a@," pp cookie) cookies;
+
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: %s=%s for domain %s" (name cookie) (value cookie)
-
(domain cookie));
+
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 name, domain, and path *)
-
jar.cookies <-
+
(* Remove existing cookie with same identity from delta *)
+
jar.delta_cookies <-
List.filter
-
(fun c ->
-
not
-
(name c = name cookie && domain c = domain cookie
-
&& path c = path cookie))
-
jar.cookies;
-
jar.cookies <- cookie :: jar.cookies;
+
(fun c -> not (cookie_identity_matches c cookie))
+
jar.delta_cookies;
+
jar.delta_cookies <- cookie :: jar.delta_cookies;
Eio.Mutex.unlock jar.mutex
-
let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure =
+
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:past_expiry
+
~max_age:(Ptime.Span.of_int_s 0) ?same_site:(same_site 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 ->
-
domain_matches (domain cookie) request_domain
+
value cookie <> ""
+
(* Exclude removal cookies *)
+
&& domain_matches (domain cookie) request_domain
&& path_matches (path cookie) request_path
&& ((not (secure cookie)) || is_secure))
-
jar.cookies
+
unique_cookies
in
-
(* Update last access time *)
+
(* Update last access time in both lists *)
let now =
Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
in
-
let updated =
+
let update_last_access cookies =
List.map
(fun c ->
-
if List.memq c applicable then
+
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)
-
?same_site:(same_site c) ~creation_time:(creation_time c)
-
~last_access:now ()
+
?max_age:(max_age c) ?same_site:(same_site c)
+
~creation_time:(creation_time c) ~last_access:now ()
else c)
-
jar.cookies
+
cookies
in
-
jar.cookies <- updated;
+
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));
···
let clear jar =
Log.info (fun m -> m "Clearing all cookies");
Eio.Mutex.lock jar.mutex;
-
jar.cookies <- [];
+
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.cookies in
-
jar.cookies <- List.filter (fun c -> not (is_expired c clock)) jar.cookies;
-
let removed = before_count - List.length jar.cookies in
+
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.cookies in
-
jar.cookies <- List.filter (fun c -> expires c <> None) jar.cookies;
-
let removed = before_count - List.length jar.cookies in
+
let before_count =
+
List.length jar.original_cookies + List.length jar.delta_cookies
+
in
+
jar.original_cookies <-
+
List.filter (fun c -> expires c <> None) jar.original_cookies;
+
jar.delta_cookies <-
+
List.filter (fun c -> expires c <> None) 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;
-
let n = List.length jar.cookies in
+
(* 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;
-
let cookies = jar.cookies in
+
(* 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;
-
cookies
+
unique
let is_empty jar =
Eio.Mutex.lock jar.mutex;
-
let empty = jar.cookies = [] in
+
let empty = jar.original_cookies = [] && jar.delta_cookies = [] in
Eio.Mutex.unlock jar.mutex;
empty
···
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"
+
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 =
···
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)))
-
jar.cookies;
+
include_subdomains (path cookie) secure_flag expires_str
+
(name cookie) (value cookie)))
+
unique;
Buffer.contents buffer
···
in
let expires =
let exp_int = try int_of_string expires with _ -> 0 in
-
if exp_int = 0 then None else Ptime.of_float_s (float_of_int exp_int)
+
if exp_int = 0 then None
+
else Ptime.of_float_s (float_of_int exp_int)
in
let cookie =
-
make ~domain ~path ~name ~value ~secure:(secure = "TRUE")
-
~http_only:false ?expires ?same_site:None ~creation_time:now
+
make ~domain:(normalize_domain domain) ~path ~name ~value
+
~secure:(secure = "TRUE") ~http_only:false ?expires
+
?max_age:None ?same_site:None ~creation_time:now
~last_access:now ()
in
-
add_cookie jar cookie;
+
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.cookies));
+
Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies));
jar
(** {1 File Operations} *)
···
create ()
let save path jar =
-
Log.info (fun m ->
-
m "Saving %d cookies to %a" (List.length jar.cookies) Eio.Path.pp path);
+
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
+63 -18
lib/cookeio.mli
···
(** Cookie management library for OCaml
-
HTTP cookies are a mechanism that allows "server side
-
connections to store and retrieve information on the client side."
-
Originally designed to enable persistent client-side state for web
-
applications, cookies are essential for storing user preferences, session
-
data, shopping cart contents, and authentication tokens.
+
HTTP cookies are a mechanism that allows "server side connections to store
+
and retrieve information on the client side." Originally designed to enable
+
persistent client-side state for web applications, cookies are essential for
+
storing user preferences, session data, shopping cart contents, and
+
authentication tokens.
This library provides a complete cookie jar implementation following
-
established web standards while integrating Eio for efficient asynchronous operations.
+
established web standards while integrating Eio for efficient asynchronous
+
operations.
{2 Cookie Format and Structure}
···
- Domain matching uses "tail matching" (e.g., "acme.com" matches
"anvil.acme.com")
- Path matching allows subset URL specification for fine-grained control
-
- More specific path mappings are sent first in Cookie headers
-
-
*)
+
- More specific path mappings are sent first in Cookie headers *)
type same_site = [ `Strict | `Lax | `None ]
(** Cookie same-site policy for controlling cross-site request behavior.
···
val expires : t -> Ptime.t option
(** Get the expiry time of a cookie *)
+
val max_age : t -> Ptime.Span.t option
+
(** Get the max-age of a cookie *)
+
val same_site : t -> same_site option
(** Get the same-site policy of a cookie *)
···
val last_access : t -> Ptime.t
(** Get the last access time of a cookie *)
-
val make : domain:string -> path:string -> name:string -> value:string ->
-
?secure:bool -> ?http_only:bool -> ?expires:Ptime.t ->
-
?same_site:same_site -> creation_time:Ptime.t -> last_access:Ptime.t ->
-
unit -> t
+
val make :
+
domain:string ->
+
path:string ->
+
name:string ->
+
value:string ->
+
?secure:bool ->
+
?http_only:bool ->
+
?expires:Ptime.t ->
+
?max_age:Ptime.Span.t ->
+
?same_site:same_site ->
+
creation_time:Ptime.t ->
+
last_access:Ptime.t ->
+
unit ->
+
t
(** Create a new cookie with the given attributes *)
(** {1 Cookie Jar Creation and Loading} *)
···
(** {1 Cookie Jar Management} *)
val add_cookie : jar -> t -> unit
-
(** Add a cookie to the jar *)
+
(** 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 ->
···
(** Get cookies applicable for a URL.
Returns all cookies that match the given domain and path, and satisfy the
-
secure flag requirement. Also updates the last access time of matching
-
cookies using the provided clock. *)
+
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 *)
···
- [SameSite=None] requires the [Secure] flag to be set
Example:
-
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/"
-
"session=abc123; Secure; HttpOnly"] *)
+
[parse_set_cookie ~clock ~domain:"example.com" ~path:"/" "session=abc123;
+
Secure; HttpOnly"] *)
val make_cookie_header : t list -> string
(** Create cookie header value from cookies.
···
Example: [make_cookie_header cookies] might return
["session=abc123; theme=dark"] *)
+
+
val make_set_cookie_header : t -> string
+
(** Create Set-Cookie header value from a cookie.
+
+
Formats a cookie into a Set-Cookie header value suitable for HTTP responses.
+
Includes all cookie attributes: Max-Age, Expires, Domain, Path, Secure,
+
HttpOnly, and SameSite. *)
(** {1 Pretty Printing} *)
+918 -38
test/test_cookeio.ml
···
(fun ppf c ->
Format.fprintf ppf
"{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \
-
expires=%a; same_site=%a }"
-
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c) (Cookeio.secure c) (Cookeio.http_only c)
+
expires=%a; max_age=%a; same_site=%a }"
+
(Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c)
+
(Cookeio.secure c) (Cookeio.http_only c)
(Format.pp_print_option Ptime.pp)
(Cookeio.expires c)
+
(Format.pp_print_option Ptime.Span.pp)
+
(Cookeio.max_age c)
(Format.pp_print_option (fun ppf -> function
| `Strict -> Format.pp_print_string ppf "Strict"
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None"))
(Cookeio.same_site c))
(fun c1 c2 ->
-
Cookeio.name c1 = Cookeio.name c2 && Cookeio.value c1 = Cookeio.value c2 && Cookeio.domain c1 = Cookeio.domain c2
-
&& Cookeio.path c1 = Cookeio.path c2 && Cookeio.secure c1 = Cookeio.secure c2
+
Cookeio.name c1 = Cookeio.name c2
+
&& Cookeio.value c1 = Cookeio.value c2
+
&& Cookeio.domain c1 = Cookeio.domain c2
+
&& Cookeio.path c1 = Cookeio.path c2
+
&& Cookeio.secure c1 = Cookeio.secure c2
&& Cookeio.http_only c1 = Cookeio.http_only c2
&& Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
+
&& Option.equal Ptime.Span.equal (Cookeio.max_age c1) (Cookeio.max_age c2)
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
let test_load_mozilla_cookies env =
···
(* Test cookie-1: session cookie on exact domain *)
let cookie1 = find_cookie "cookie-1" in
-
Alcotest.(check string) "cookie-1 domain" "example.com" (Cookeio.domain cookie1);
+
Alcotest.(check string)
+
"cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check string) "cookie-1 path" "/foo/" (Cookeio.path cookie1);
Alcotest.(check string) "cookie-1 name" "cookie-1" (Cookeio.name cookie1);
Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1);
···
| `Lax -> Format.pp_print_string ppf "Lax"
| `None -> Format.pp_print_string ppf "None")
( = ))))
-
"cookie-1 same_site" None (Cookeio.same_site cookie1);
+
"cookie-1 same_site" None
+
(Cookeio.same_site cookie1);
(* Test cookie-2: session cookie on subdomain pattern *)
let cookie2 = find_cookie "cookie-2" in
-
Alcotest.(check string) "cookie-2 domain" ".example.com" (Cookeio.domain cookie2);
+
Alcotest.(check string)
+
"cookie-2 domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check string) "cookie-2 path" "/foo/" (Cookeio.path cookie2);
Alcotest.(check string) "cookie-2 name" "cookie-2" (Cookeio.name cookie2);
Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2);
···
(* Test cookie-3: non-session cookie with expiry *)
let cookie3 = find_cookie "cookie-3" in
let expected_expiry = Ptime.of_float_s 1257894000.0 in
-
Alcotest.(check string) "cookie-3 domain" "example.com" (Cookeio.domain cookie3);
+
Alcotest.(check string)
+
"cookie-3 domain" "example.com" (Cookeio.domain cookie3);
Alcotest.(check string) "cookie-3 path" "/foo/" (Cookeio.path cookie3);
Alcotest.(check string) "cookie-3 name" "cookie-3" (Cookeio.name cookie3);
Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3);
···
(* Test cookie-4: another non-session cookie *)
let cookie4 = find_cookie "cookie-4" in
-
Alcotest.(check string) "cookie-4 domain" "example.com" (Cookeio.domain cookie4);
+
Alcotest.(check string)
+
"cookie-4 domain" "example.com" (Cookeio.domain cookie4);
Alcotest.(check string) "cookie-4 path" "/foo/" (Cookeio.path cookie4);
Alcotest.(check string) "cookie-4 name" "cookie-4" (Cookeio.name cookie4);
Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4);
···
(* Test cookie-5: secure cookie *)
let cookie5 = find_cookie "cookie-5" in
-
Alcotest.(check string) "cookie-5 domain" "example.com" (Cookeio.domain cookie5);
+
Alcotest.(check string)
+
"cookie-5 domain" "example.com" (Cookeio.domain cookie5);
Alcotest.(check string) "cookie-5 path" "/foo/" (Cookeio.path cookie5);
Alcotest.(check string) "cookie-5 name" "cookie-5" (Cookeio.name cookie5);
Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5);
···
(* Verify a few key cookies are loaded correctly *)
let cookie1 = find_cookie "cookie-1" in
Alcotest.(check string) "file cookie-1 value" "v$1" (Cookeio.value cookie1);
-
Alcotest.(check string) "file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
+
Alcotest.(check string)
+
"file cookie-1 domain" "example.com" (Cookeio.domain cookie1);
Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1);
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"file cookie-1 expires" None (Cookeio.expires cookie1);
···
(* Verify subdomain cookie *)
let cookie2 = find_cookie "cookie-2" in
-
Alcotest.(check string) "file cookie-2 domain" ".example.com" (Cookeio.domain cookie2);
+
Alcotest.(check string)
+
"file cookie-2 domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
"file cookie-2 expires" None (Cookeio.expires cookie2)
···
(* Add test cookies with different domain patterns *)
let exact_cookie =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1"
-
~secure:false ~http_only:false ?expires:None ?same_site:None
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
in
let subdomain_cookie =
-
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"subdomain" ~value:"test2"
-
~secure:false ~http_only:false ?expires:None ?same_site:None
-
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"subdomain"
+
~value:"test2" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None ~creation_time:Ptime.epoch
+
~last_access:Ptime.epoch ()
in
let secure_cookie =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3"
-
~secure:true ~http_only:false ?expires:None ?same_site:None
+
~secure:true ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
in
···
add_cookie jar subdomain_cookie;
add_cookie jar secure_cookie;
-
(* Test exact domain matching *)
+
(* Test exact domain matching - all three cookies should match example.com *)
let cookies_http =
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
in
···
in
Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
-
(* Test subdomain matching *)
+
(* Test subdomain matching - all cookies should match subdomains now *)
let cookies_sub =
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
in
-
Alcotest.(check int) "subdomain cookies count" 1 (List.length cookies_sub);
-
let sub_cookie = List.hd cookies_sub in
-
Alcotest.(check string) "subdomain cookie name" "subdomain" (Cookeio.name sub_cookie)
+
Alcotest.(check int) "subdomain cookies count" 2 (List.length cookies_sub)
let test_empty_jar env =
let clock = Eio.Stdenv.clock env in
···
let jar = create () in
let test_cookie =
-
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test" ~value:"value"
-
~secure:true ~http_only:false ?expires:(Ptime.of_float_s 1257894000.0)
-
~same_site:`Strict ~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
+
Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test"
+
~value:"value" ~secure:true ~http_only:false
+
?expires:(Ptime.of_float_s 1257894000.0)
+
~same_site:`Strict ?max_age:None ~creation_time:Ptime.epoch
+
~last_access:Ptime.epoch ()
in
add_cookie jar test_cookie;
···
let cookie2 = List.hd cookies2 in
Alcotest.(check string) "round trip name" "test" (Cookeio.name cookie2);
Alcotest.(check string) "round trip value" "value" (Cookeio.value cookie2);
-
Alcotest.(check string) "round trip domain" "example.com" (Cookeio.domain cookie2);
+
Alcotest.(check string)
+
"round trip domain" "example.com" (Cookeio.domain cookie2);
Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2);
Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2);
(* Note: http_only and same_site are lost in Mozilla format *)
···
let cookie1 =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
~value:"value1" ~secure:false ~http_only:false ~expires:expires_soon
-
?same_site:None
+
?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
()
···
let cookie2 =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
~value:"value2" ~secure:false ~http_only:false ~expires:expires_later
-
?same_site:None
+
?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
()
···
(* Add a session cookie (no expiry) *)
let cookie3 =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3"
-
~secure:false ~http_only:false ?expires:None ?same_site:None
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
()
···
let cookies = get_all_cookies jar in
let names = List.map Cookeio.name cookies |> List.sort String.compare in
Alcotest.(check (list string))
-
"remaining cookies after 1600s" [ "expires_later"; "session" ] names;
+
"remaining cookies after 1600s"
+
[ "expires_later"; "session" ]
+
names;
(* Advance time to 2100.0 - second cookie should expire *)
Eio_mock.Clock.set_time clock 2100.0;
···
Alcotest.(check int) "after second expiry" 1 (count jar);
let remaining = get_all_cookies jar in
-
Alcotest.(check string) "only session cookie remains" "session"
+
Alcotest.(check string)
+
"only session cookie remains" "session"
(Cookeio.name (List.hd remaining))
let test_max_age_parsing_with_mock_clock () =
···
(* Add a cookie *)
let cookie =
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
-
~secure:false ~http_only:false ?expires:None ?same_site:None
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
~creation_time:(Ptime.of_float_s 3000.0 |> Option.get)
~last_access:(Ptime.of_float_s 3000.0 |> Option.get)
()
···
let cookie = Option.get cookie_opt in
Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie);
Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie);
-
Alcotest.(check string) "cookie domain" ".example.com" (Cookeio.domain cookie);
+
Alcotest.(check string) "cookie domain" "example.com" (Cookeio.domain cookie);
Alcotest.(check string) "cookie path" "/" (Cookeio.path cookie);
(* Verify expires is parsed correctly *)
-
Alcotest.(check bool) "has expiry" true
+
Alcotest.(check bool)
+
"has expiry" true
(Option.is_some (Cookeio.expires cookie));
(* Verify the specific expiry time parsed from the RFC3339 date *)
···
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" invalid_header
in
-
Alcotest.(check bool) "invalid cookie rejected" true (Option.is_none cookie_opt);
+
Alcotest.(check bool)
+
"invalid cookie rejected" true
+
(Option.is_none cookie_opt);
(* This should be accepted: SameSite=None with Secure *)
let valid_header = "token=abc; SameSite=None; Secure" in
···
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" valid_header
in
-
Alcotest.(check bool) "valid cookie accepted" true (Option.is_some cookie_opt2);
+
Alcotest.(check bool)
+
"valid cookie accepted" true
+
(Option.is_some cookie_opt2);
let cookie = Option.get cookie_opt2 in
Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie);
···
( = ))))
"samesite is None" (Some `None) (Cookeio.same_site cookie)
+
let test_domain_normalization () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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
+
Alcotest.(check string)
+
"domain normalized" "example.com" (Cookeio.domain cookie);
+
+
(* Test round-trip through Mozilla format normalizes domains *)
+
let jar = create () in
+
let test_cookie =
+
Cookeio.make ~domain:".example.com" ~path:"/" ~name:"test" ~value:"val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar test_cookie;
+
+
let mozilla_format = to_mozilla_format jar in
+
let jar2 = from_mozilla_format ~clock mozilla_format in
+
let cookies2 = get_all_cookies jar2 in
+
Alcotest.(check int) "one cookie" 1 (List.length cookies2);
+
Alcotest.(check string)
+
"domain normalized after round-trip" "example.com"
+
(Cookeio.domain (List.hd cookies2))
+
+
let test_max_age_stored_separately () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* 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);
+
+
let cookie = Option.get cookie_opt in
+
+
(* Verify max_age is stored as a Ptime.Span *)
+
Alcotest.(check bool)
+
"max_age is set" true
+
(Option.is_some (Cookeio.max_age cookie));
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
+
Alcotest.(check (option int))
+
"max_age is 3600 seconds" (Some 3600)
+
(Ptime.Span.to_int_s max_age_span);
+
+
(* Verify expires is also computed correctly *)
+
let expected_expiry = Ptime.of_float_s 8600.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires computed from max-age" expected_expiry (Cookeio.expires cookie)
+
+
let test_max_age_negative_becomes_zero () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* 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);
+
+
let cookie = Option.get cookie_opt in
+
+
(* Verify max_age is stored as 0 per RFC 6265 *)
+
Alcotest.(check bool)
+
"max_age is set" true
+
(Option.is_some (Cookeio.max_age cookie));
+
let max_age_span = Option.get (Cookeio.max_age cookie) in
+
Alcotest.(check (option int))
+
"negative max_age becomes 0" (Some 0)
+
(Ptime.Span.to_int_s max_age_span);
+
+
(* Verify expires is computed with 0 seconds *)
+
let expected_expiry = Ptime.of_float_s 5000.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires computed with 0 seconds" expected_expiry (Cookeio.expires cookie)
+
+
let string_contains_substring s sub =
+
try
+
let len = String.length sub in
+
let rec search i =
+
if i + len > String.length s then false
+
else if String.sub s i len = sub then true
+
else search (i + 1)
+
in
+
search 0
+
with _ -> false
+
+
let test_make_set_cookie_header_includes_max_age () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Create a cookie with max_age *)
+
let max_age_span = Ptime.Span.of_int_s 3600 in
+
let expires_time = Ptime.of_float_s 8600.0 |> Option.get in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"abc123"
+
~secure:true ~http_only:true ?expires:(Some expires_time)
+
?max_age:(Some max_age_span) ?same_site:(Some `Strict)
+
~creation_time:(Ptime.of_float_s 5000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 5000.0 |> Option.get)
+
()
+
in
+
+
let header = make_set_cookie_header cookie in
+
+
(* Verify the header includes Max-Age *)
+
Alcotest.(check bool)
+
"header includes Max-Age" true
+
(string_contains_substring header "Max-Age=3600");
+
+
(* Verify the header includes Expires *)
+
Alcotest.(check bool)
+
"header includes Expires" true
+
(string_contains_substring header "Expires=");
+
+
(* Verify the header includes other attributes *)
+
Alcotest.(check bool)
+
"header includes Secure" true
+
(string_contains_substring header "Secure");
+
Alcotest.(check bool)
+
"header includes HttpOnly" true
+
(string_contains_substring header "HttpOnly");
+
Alcotest.(check bool)
+
"header includes SameSite" true
+
(string_contains_substring header "SameSite=Strict")
+
+
let test_max_age_round_trip () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* 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
+
+
(* Generate Set-Cookie header from the cookie *)
+
let set_cookie_header = make_set_cookie_header cookie in
+
+
(* Parse it back *)
+
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
+
+
(* Verify max_age is preserved *)
+
Alcotest.(check (option int))
+
"max_age preserved"
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie)))
+
(Ptime.Span.to_int_s (Option.get (Cookeio.max_age cookie2)))
+
+
let test_domain_matching () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 2000.0;
+
+
let jar = create () in
+
+
(* Create a cookie with domain "example.com" *)
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 2000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 2000.0 |> Option.get)
+
()
+
in
+
add_cookie jar cookie;
+
+
(* Test "example.com" cookie matches "example.com" request *)
+
let cookies1 =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "matches exact domain" 1 (List.length cookies1);
+
+
(* Test "example.com" cookie matches "sub.example.com" request *)
+
let cookies2 =
+
get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "matches subdomain" 1 (List.length cookies2);
+
+
(* Test "example.com" cookie matches "deep.sub.example.com" request *)
+
let cookies3 =
+
get_cookies jar ~clock ~domain:"deep.sub.example.com" ~path:"/"
+
~is_secure:false
+
in
+
Alcotest.(check int) "matches deep subdomain" 1 (List.length cookies3);
+
+
(* Test "example.com" cookie doesn't match "notexample.com" *)
+
let cookies4 =
+
get_cookies jar ~clock ~domain:"notexample.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "doesn't match different domain" 0 (List.length cookies4);
+
+
(* Test "example.com" cookie doesn't match "fakeexample.com" *)
+
let cookies5 =
+
get_cookies jar ~clock ~domain:"fakeexample.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "doesn't match prefix domain" 0 (List.length cookies5)
+
+
(** {1 HTTP Date Parsing Tests} *)
+
+
let test_http_date_fmt1 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
"FMT1 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the parsed time matches expected value *)
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT1 expiry correct" expected (Cookeio.expires cookie)
+
+
let test_http_date_fmt2 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
"FMT2 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Year 15 should be normalized to 2015 *)
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT2 expiry correct with year normalization" expected
+
(Cookeio.expires cookie)
+
+
let test_http_date_fmt3 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
"FMT3 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT3 expiry correct" expected (Cookeio.expires cookie)
+
+
let test_http_date_fmt4 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
"FMT4 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
let expected = Ptime.of_date_time ((2015, 10, 21), ((07, 28, 00), 0)) in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"FMT4 expiry correct" expected (Cookeio.expires cookie)
+
+
let test_abbreviated_year_69_to_99 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 95 becomes 1995" expected (Cookeio.expires cookie);
+
+
(* 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
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 69 becomes 1969" expected2 (Cookeio.expires cookie2);
+
+
(* 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
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 99 becomes 1999" expected3 (Cookeio.expires cookie3)
+
+
let test_abbreviated_year_0_to_68 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 25 becomes 2025" expected (Cookeio.expires cookie);
+
+
(* 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
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 0 becomes 2000" expected2 (Cookeio.expires cookie2);
+
+
(* 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
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"year 68 becomes 2068" expected3 (Cookeio.expires cookie3)
+
+
let test_rfc3339_still_works () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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
+
(Option.is_some cookie_opt);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
"RFC 3339 has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the time was parsed correctly *)
+
let expected = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
+
match expected with
+
| Ok (time, _, _) ->
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"RFC 3339 expiry correct" (Some time) (Cookeio.expires cookie)
+
| Error _ -> Alcotest.fail "Failed to parse expected RFC 3339 time"
+
+
let test_invalid_date_format_logs_warning () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* 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 *)
+
Alcotest.(check bool)
+
"cookie parsed despite invalid date" true
+
(Option.is_some cookie_opt);
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check string) "cookie name correct" "session" (Cookeio.name cookie);
+
Alcotest.(check string) "cookie value correct" "abc" (Cookeio.value cookie);
+
(* expires should be None since date was invalid *)
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires is None for invalid date" None (Cookeio.expires cookie)
+
+
let test_case_insensitive_month_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test various case combinations for month names *)
+
let test_cases =
+
[
+
("session=abc; Expires=Wed, 21 oct 2015 07:28:00 GMT", "lowercase month");
+
("session=abc; Expires=Wed, 21 OCT 2015 07:28:00 GMT", "uppercase month");
+
("session=abc; Expires=Wed, 21 OcT 2015 07:28:00 GMT", "mixed case month");
+
("session=abc; Expires=Wed, 21 oCt 2015 07:28:00 GMT", "weird case month");
+
]
+
in
+
+
List.iter
+
(fun (header, description) ->
+
let cookie_opt =
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
(description ^ " parsed") true
+
(Option.is_some cookie_opt);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
(description ^ " has expiry")
+
true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the date was parsed correctly regardless of case *)
+
let expires = Option.get (Cookeio.expires cookie) in
+
let year, month, _ = Ptime.to_date expires in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
Alcotest.(check int)
+
(description ^ " month correct (October=10)")
+
10 month)
+
test_cases
+
+
let test_case_insensitive_gmt_parsing () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
(* Test various case combinations for GMT timezone *)
+
let test_cases =
+
[
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "uppercase GMT");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 gmt", "lowercase gmt");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 Gmt", "mixed case Gmt");
+
("session=abc; Expires=Wed, 21 Oct 2015 07:28:00 GmT", "weird case GmT");
+
]
+
in
+
+
List.iter
+
(fun (header, description) ->
+
let cookie_opt =
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
in
+
Alcotest.(check bool)
+
(description ^ " parsed") true
+
(Option.is_some cookie_opt);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check bool)
+
(description ^ " has expiry")
+
true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the date was parsed correctly regardless of GMT case *)
+
let expires = Option.get (Cookeio.expires cookie) in
+
let year, month, day = Ptime.to_date expires in
+
Alcotest.(check int) (description ^ " year correct") 2015 year;
+
Alcotest.(check int)
+
(description ^ " month correct (October=10)")
+
10 month;
+
Alcotest.(check int) (description ^ " day correct") 21 day)
+
test_cases
+
+
(** {1 Delta Tracking Tests} *)
+
+
let test_add_original_not_in_delta () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
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 *)
+
Alcotest.(check int) "jar count is 1" 1 (count jar)
+
+
let test_add_cookie_appears_in_delta () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar cookie;
+
+
(* 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);
+
Alcotest.(check string)
+
"delta cookie value" "value"
+
(Cookeio.value delta_cookie)
+
+
let test_remove_original_creates_removal_cookie () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
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)
+
"removal cookie name" "test"
+
(Cookeio.name removal_cookie);
+
Alcotest.(check string)
+
"removal cookie has empty value" ""
+
(Cookeio.value removal_cookie);
+
+
(* Check Max-Age is 0 *)
+
match Cookeio.max_age removal_cookie with
+
| Some span ->
+
Alcotest.(check (option int))
+
"removal cookie Max-Age is 0" (Some 0) (Ptime.Span.to_int_s span)
+
| None -> Alcotest.fail "removal cookie should have Max-Age"
+
+
let test_remove_delta_cookie_removes_it () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar cookie;
+
+
(* 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)
+
+
let test_get_cookies_combines_original_and_delta () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add an original cookie *)
+
let original =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"original"
+
~value:"orig_val" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original;
+
+
(* Add a delta cookie *)
+
let delta_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"delta"
+
~value:"delta_val" ~secure:false ~http_only:false ?expires:None
+
?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar delta_cookie;
+
+
(* Get cookies should return both *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "both cookies returned" 2 (List.length cookies);
+
+
let names = List.map Cookeio.name cookies |> List.sort String.compare in
+
Alcotest.(check (list string)) "cookie names" [ "delta"; "original" ] names
+
+
let test_get_cookies_delta_takes_precedence () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add an original cookie *)
+
let original =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"orig_val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original;
+
+
(* Add a delta cookie with the same name/domain/path *)
+
let delta_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"delta_val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_cookie jar delta_cookie;
+
+
(* Get cookies should return only the delta cookie *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "only one cookie returned" 1 (List.length cookies);
+
let cookie = List.hd cookies in
+
Alcotest.(check string)
+
"delta cookie value" "delta_val" (Cookeio.value cookie)
+
+
let test_get_cookies_excludes_removal_cookies () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add an original cookie *)
+
let original =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original;
+
+
(* Remove it *)
+
Cookeio.remove jar ~clock original;
+
+
(* Get cookies should return nothing *)
+
let cookies =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
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 () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add original cookies *)
+
let original1 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig1" ~value:"val1"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original1;
+
+
let original2 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"orig2" ~value:"val2"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
add_original jar original2;
+
+
(* Add a new delta cookie *)
+
let new_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"new" ~value:"new_val"
+
~secure:false ~http_only:false ?expires:None ?same_site:None ?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
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)
+
+
let test_removal_cookie_format () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
let cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value"
+
~secure:true ~http_only:true ?expires:None ~same_site:`Strict
+
?max_age:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
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 *)
+
Alcotest.(check string)
+
"removal cookie has empty value" "" (Cookeio.value removal);
+
Alcotest.(check (option int))
+
"removal cookie Max-Age is 0" (Some 0)
+
(Option.bind (Cookeio.max_age removal) Ptime.Span.to_int_s);
+
+
(* Check expires is in the past *)
+
let now = Ptime.of_float_s 1000.0 |> Option.get in
+
match Cookeio.expires removal with
+
| Some exp ->
+
Alcotest.(check bool)
+
"expires is in the past" true
+
(Ptime.compare exp now < 0)
+
| None -> Alcotest.fail "removal cookie should have expires"
+
let () =
Eio_main.run @@ fun env ->
let open Alcotest in
···
test_cookie_matching env);
] );
( "basic_operations",
-
[ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ]
-
);
+
[
+
test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env);
+
] );
( "time_handling",
[
test_case "Cookie expiry with mock clock" `Quick
···
test_parse_set_cookie_with_expires;
test_case "SameSite=None validation" `Quick
test_samesite_none_validation;
+
] );
+
( "domain_normalization",
+
[
+
test_case "Domain normalization" `Quick test_domain_normalization;
+
test_case "Domain matching with normalized domains" `Quick
+
test_domain_matching;
+
] );
+
( "max_age_tracking",
+
[
+
test_case "Max-Age stored separately from Expires" `Quick
+
test_max_age_stored_separately;
+
test_case "Negative Max-Age becomes 0" `Quick
+
test_max_age_negative_becomes_zero;
+
test_case "make_set_cookie_header includes Max-Age" `Quick
+
test_make_set_cookie_header_includes_max_age;
+
test_case "Max-Age round-trip parsing" `Quick test_max_age_round_trip;
+
] );
+
( "delta_tracking",
+
[
+
test_case "add_original doesn't affect delta" `Quick
+
test_add_original_not_in_delta;
+
test_case "add_cookie appears in delta" `Quick
+
test_add_cookie_appears_in_delta;
+
test_case "remove original creates removal cookie" `Quick
+
test_remove_original_creates_removal_cookie;
+
test_case "remove delta cookie just removes it" `Quick
+
test_remove_delta_cookie_removes_it;
+
test_case "get_cookies combines original and delta" `Quick
+
test_get_cookies_combines_original_and_delta;
+
test_case "get_cookies delta takes precedence" `Quick
+
test_get_cookies_delta_takes_precedence;
+
test_case "get_cookies excludes removal cookies" `Quick
+
test_get_cookies_excludes_removal_cookies;
+
test_case "delta returns only changed cookies" `Quick
+
test_delta_returns_only_changed_cookies;
+
test_case "removal cookie format" `Quick test_removal_cookie_format;
+
] );
+
( "http_date_parsing",
+
[
+
test_case "HTTP date FMT1 (RFC 1123)" `Quick test_http_date_fmt1;
+
test_case "HTTP date FMT2 (RFC 850)" `Quick test_http_date_fmt2;
+
test_case "HTTP date FMT3 (asctime)" `Quick test_http_date_fmt3;
+
test_case "HTTP date FMT4 (variant)" `Quick test_http_date_fmt4;
+
test_case "Abbreviated year 69-99 becomes 1900+" `Quick
+
test_abbreviated_year_69_to_99;
+
test_case "Abbreviated year 0-68 becomes 2000+" `Quick
+
test_abbreviated_year_0_to_68;
+
test_case "RFC 3339 backward compatibility" `Quick
+
test_rfc3339_still_works;
+
test_case "Invalid date format logs warning" `Quick
+
test_invalid_date_format_logs_warning;
+
test_case "Case-insensitive month parsing" `Quick
+
test_case_insensitive_month_parsing;
+
test_case "Case-insensitive GMT parsing" `Quick
+
test_case_insensitive_gmt_parsing;
] );
]