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

init import

+2
.gitignore
···
···
+
_build
+
.*.swp
+1
.ocamlformat
···
···
+
version=0.27.0
+18
LICENSE.md
···
···
+
(*
+
* ISC License
+
*
+
* Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
+
*
+
* Permission to use, copy, modify, and distribute this software for any
+
* purpose with or without fee is hereby granted, provided that the above
+
* copyright notice and this permission notice appear in all copies.
+
*
+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
*
+
*)
+64
README.md
···
···
+
# Cookeio - HTTP Cookie Management for OCaml
+
+
Cookeio is an OCaml library for managing HTTP cookies.
+
+
## Overview
+
+
HTTP cookies are a mechanism for maintaining client-side state in web
+
applications. Originally specified to allow "server side connections to store
+
and retrieve information on the client side," cookies enable persistent storage
+
of user preferences, session data, shopping cart contents, and authentication
+
tokens.
+
+
This library provides a complete cookie jar implementation following
+
established standards while integrating with OCaml's for efficient asynchronous
+
operations.
+
+
## Cookie Attributes
+
+
The library supports all standard HTTP cookie attributes:
+
+
- **Domain**: Controls which domains can access the cookie using tail matching
+
- **Path**: Defines URL subsets where the cookie is valid
+
- **Secure**: Restricts transmission to HTTPS connections only
+
- **HttpOnly**: Prevents JavaScript access to the cookie
+
- **Expires**: Sets cookie lifetime (session cookies when omitted)
+
- **SameSite**: Controls cross-site request behavior (`Strict`, `Lax`, or `None`)
+
+
## Usage
+
+
```ocaml
+
(* Create a new cookie jar *)
+
let jar = Cookeio.create () in
+
+
(* Parse a Set-Cookie header *)
+
let cookie = Cookeio.parse_set_cookie
+
~domain:"example.com"
+
~path:"/"
+
"session=abc123; Secure; HttpOnly; SameSite=Strict" in
+
+
(* Add cookie to jar *)
+
Option.iter (Cookeio.add_cookie jar) cookie;
+
+
(* Get cookies for a request *)
+
let cookies = Cookeio.get_cookies jar
+
~domain:"example.com"
+
~path:"/api"
+
~is_secure:true in
+
+
(* Generate Cookie header *)
+
let header = Cookeio.make_cookie_header cookies
+
```
+
+
## Storage and Persistence
+
+
Cookies can be persisted to disk in Mozilla format for compatibility with other
+
tools:
+
+
```ocaml
+
(* Save cookies to file *)
+
Cookeio.save (Eio.Path.of_string "cookies.txt") jar;
+
+
(* Load cookies from file *)
+
let jar = Cookeio.load (Eio.Path.of_string "cookies.txt")
+
```
+35
cookeio.opam
···
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
synopsis: "Cookie parsing and management library using Eio"
+
description:
+
"Cookeio provides cookie management functionality for OCaml applications, including parsing Set-Cookie headers, managing cookie jars, and supporting the Mozilla cookies.txt format for persistence."
+
maintainer: ["Anil Madhavapeddy"]
+
authors: ["Anil Madhavapeddy"]
+
license: "ISC"
+
homepage: "https://github.com/avsm/cookeio"
+
bug-reports: "https://github.com/avsm/cookeio/issues"
+
depends: [
+
"ocaml" {>= "5.2.0"}
+
"dune" {>= "3.19"}
+
"eio" {>= "1.0"}
+
"logs" {>= "0.9.0"}
+
"ptime" {>= "1.1.0"}
+
"alcotest" {with-test}
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+
dev-repo: "git+https://github.com/avsm/cookeio.git"
+
x-maintenance-intent: ["(latest)"]
+23
dune-project
···
···
+
(lang dune 3.19)
+
+
(name cookeio)
+
+
(generate_opam_files true)
+
+
(source (github avsm/cookeio))
+
+
(authors "Anil Madhavapeddy")
+
(maintainers "Anil Madhavapeddy")
+
(license ISC)
+
+
(package
+
(name cookeio)
+
(synopsis "Cookie parsing and management library using Eio")
+
(description "Cookeio provides cookie management functionality for OCaml applications, including parsing Set-Cookie headers, managing cookie jars, and supporting the Mozilla cookies.txt format for persistence.")
+
(depends
+
(ocaml (>= 5.2.0))
+
dune
+
(eio (>= 1.0))
+
(logs (>= 0.9.0))
+
(ptime (>= 1.1.0))
+
(alcotest :with-test)))
+397
lib/cookeio.ml
···
···
+
let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
+
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
type same_site = [ `Strict | `Lax | `None ]
+
(** Cookie same-site policy *)
+
+
type t = {
+
domain : string;
+
path : string;
+
name : string;
+
value : string;
+
secure : bool;
+
http_only : bool;
+
expires : Ptime.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 }
+
(** 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 secure cookie = cookie.secure
+
let http_only cookie = cookie.http_only
+
let expires cookie = cookie.expires
+
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 }
+
+
(** {1 Cookie Jar Creation} *)
+
+
let create () =
+
Log.debug (fun m -> m "Creating new empty cookie jar");
+
{ cookies = []; mutex = Eio.Mutex.create () }
+
+
(** {1 Cookie Matching Helpers} *)
+
+
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
+
+
let path_matches cookie_path request_path =
+
(* Cookie path /foo matches /foo, /foo/, /foo/bar *)
+
String.starts_with ~prefix:cookie_path request_path
+
+
let is_expired cookie clock =
+
match cookie.expires with
+
| None -> false (* Session cookie *)
+
| Some exp_time ->
+
let now =
+
Ptime.of_float_s (Eio.Time.now clock)
+
|> Option.value ~default:Ptime.epoch
+
in
+
Ptime.compare now exp_time > 0
+
+
(** {1 Cookie Parsing} *)
+
+
let parse_cookie_attribute attr attr_value cookie =
+
let attr_lower = String.lowercase_ascii attr in
+
match attr_lower with
+
| "domain" -> make ~domain:attr_value ~path:(path cookie) ~name:(name cookie)
+
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
+
?expires:(expires cookie) ?same_site:(same_site cookie)
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
| "path" -> make ~domain:(domain cookie) ~path:attr_value ~name:(name cookie)
+
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
+
?expires:(expires cookie) ?same_site:(same_site cookie)
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
| "expires" -> (
+
try
+
let time, _tz_offset, _tz_string =
+
Ptime.of_rfc3339 attr_value |> Result.get_ok
+
in
+
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
+
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
+
~expires:time ?same_site:(same_site cookie)
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
with _ ->
+
Log.debug (fun m -> m "Failed to parse expires: %s" attr_value);
+
cookie)
+
| "max-age" -> (
+
try
+
let seconds = int_of_string attr_value in
+
let now = Unix.time () in
+
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
+
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
+
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
+
?expires ?same_site:(same_site cookie)
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
with _ -> cookie)
+
| "secure" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
+
~value:(value cookie) ~secure:true ~http_only:(http_only cookie)
+
?expires:(expires cookie) ?same_site:(same_site cookie)
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
| "httponly" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
+
~value:(value cookie) ~secure:(secure cookie) ~http_only:true
+
?expires:(expires cookie) ?same_site:(same_site cookie)
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
| "samesite" ->
+
let same_site_val =
+
match String.lowercase_ascii attr_value with
+
| "strict" -> Some `Strict
+
| "lax" -> Some `Lax
+
| "none" -> Some `None
+
| _ -> None
+
in
+
make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
+
~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
+
?expires:(expires cookie) ?same_site:same_site_val
+
~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
+
| _ -> cookie
+
+
let rec parse_set_cookie ~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 (Unix.time ()) |> Option.value ~default:Ptime.epoch
+
in
+
let base_cookie =
+
make ~domain:request_domain ~path:request_path ~name ~value:cookie_value ~secure:false ~http_only:false
+
?expires:None ?same_site:None ~creation_time:now ~last_access:now ()
+
in
+
+
(* Parse attributes *)
+
let cookie =
+
List.fold_left
+
(fun cookie attr ->
+
match String.index_opt attr '=' with
+
| None -> parse_cookie_attribute attr "" cookie
+
| 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_cookie_attribute attr_name attr_value cookie)
+
base_cookie attrs
+
in
+
+
Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
+
Some cookie)
+
+
and make_cookie_header cookies =
+
cookies
+
|> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
+
|> String.concat "; "
+
+
(** {1 Pretty Printing} *)
+
+
and pp_same_site ppf = function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None"
+
+
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 }@]"
+
(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 pp_same_site)
+
(same_site cookie)
+
+
let pp_jar ppf jar =
+
Eio.Mutex.lock jar.mutex;
+
let cookies = jar.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;
+
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));
+
+
Eio.Mutex.lock jar.mutex;
+
(* Remove existing cookie with same name, domain, and path *)
+
jar.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;
+
Eio.Mutex.unlock jar.mutex
+
+
let get_cookies jar ~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;
+
let applicable =
+
List.filter
+
(fun cookie ->
+
domain_matches (domain cookie) request_domain
+
&& path_matches (path cookie) request_path
+
&& ((not (secure cookie)) || is_secure))
+
jar.cookies
+
in
+
+
(* Update last access time *)
+
let now =
+
Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch
+
in
+
let updated =
+
List.map
+
(fun c ->
+
if List.memq 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 ()
+
else c)
+
jar.cookies
+
in
+
jar.cookies <- updated;
+
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.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
+
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
+
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
+
Eio.Mutex.unlock jar.mutex;
+
n
+
+
let get_all_cookies jar =
+
Eio.Mutex.lock jar.mutex;
+
let cookies = jar.cookies in
+
Eio.Mutex.unlock jar.mutex;
+
cookies
+
+
let is_empty jar =
+
Eio.Mutex.lock jar.mutex;
+
let empty = jar.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";
+
+
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" (* Session cookie *)
+
| Some 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)))
+
jar.cookies;
+
+
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 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 (Unix.time ())
+
|> 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 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 ~last_access:now ()
+
in
+
add_cookie 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));
+
jar
+
+
(** {1 File Operations} *)
+
+
let load 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 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 =
+
Log.info (fun m ->
+
m "Saving %d cookies to %a" (List.length jar.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))
+181
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.
+
+
This library provides a complete cookie jar implementation following
+
established web standards while integrating Eio for efficient asynchronous operations.
+
+
{2 Cookie Format and Structure}
+
+
Cookies are set via the Set-Cookie HTTP response header with the basic
+
format: [NAME=VALUE] with optional attributes including:
+
- [expires]: Optional cookie lifetime specification
+
- [domain]: Specifying valid domains using tail matching
+
- [path]: Defining URL subset for cookie validity
+
- [secure]: Transmission over secure channels only
+
- [httponly]: Not accessible to JavaScript
+
- [samesite]: Cross-site request behavior control
+
+
{2 Domain and Path Matching}
+
+
The library implements standard domain and path matching rules:
+
- 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
+
+
*)
+
+
type same_site = [ `Strict | `Lax | `None ]
+
(** Cookie same-site policy for controlling cross-site request behavior.
+
+
- [`Strict]: Cookie only sent for same-site requests, providing maximum
+
protection
+
- [`Lax]: Cookie sent for same-site requests and top-level navigation
+
(default for modern browsers)
+
- [`None]: Cookie sent for all cross-site requests (requires [secure] flag)
+
*)
+
+
type t
+
(** HTTP Cookie representation with all standard attributes.
+
+
A cookie represents a name-value pair with associated metadata that controls
+
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
+
(** Get the domain of a cookie *)
+
+
val path : t -> string
+
(** Get the path of a cookie *)
+
+
val name : t -> string
+
(** Get the name of a cookie *)
+
+
val value : t -> string
+
(** Get the value of a cookie *)
+
+
val secure : t -> bool
+
(** Check if cookie is secure only *)
+
+
val http_only : t -> bool
+
(** Check if cookie is HTTP only *)
+
+
val expires : t -> Ptime.t option
+
(** Get the expiry time of a cookie *)
+
+
val same_site : t -> same_site option
+
(** Get the same-site policy of a cookie *)
+
+
val creation_time : t -> Ptime.t
+
(** Get the creation time 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
+
(** Create a new cookie with the given attributes *)
+
+
(** {1 Cookie Jar Creation and Loading} *)
+
+
val create : unit -> jar
+
(** Create an empty cookie jar *)
+
+
val load : Eio.Fs.dir_ty Eio.Path.t -> jar
+
(** Load cookies from Mozilla format file *)
+
+
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 *)
+
+
val get_cookies :
+
jar -> domain:string -> path:string -> is_secure:bool -> t list
+
(** Get cookies applicable for a URL *)
+
+
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 : 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]
+
- Supports all standard attributes: [expires], [domain], [path], [secure],
+
[httponly], [samesite]
+
- Returns [None] if parsing fails or cookie is invalid
+
- The [domain] and [path] parameters provide the request context for default
+
values
+
+
Example:
+
[parse_set_cookie ~domain:"example.com" ~path:"/" "session=abc123; Secure;
+
HttpOnly"] *)
+
+
val make_cookie_header : t list -> string
+
(** Create cookie header value from cookies.
+
+
Formats a list of cookies into a Cookie header value suitable for HTTP
+
requests.
+
- Format: [name1=value1; name2=value2; name3=value3]
+
- Only includes cookie names and values, not attributes
+
- Cookies should already be filtered for the target domain/path
+
- More specific path mappings should be ordered first in the input list
+
+
Example: [make_cookie_header cookies] might return
+
["session=abc123; theme=dark"] *)
+
+
(** {1 Pretty Printing} *)
+
+
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 : string -> jar
+
(** Parse Mozilla format cookies *)
+4
lib/dune
···
···
+
(library
+
(name cookeio)
+
(public_name cookeio)
+
(libraries eio logs ptime unix))
+11
test/cookies.txt
···
···
+
# Netscape HTTP Cookie File
+
# http://curl.haxx.se/rfc/cookie_spec.html
+
# This is a generated file! Do not edit.
+
+
example.com FALSE /foo/ FALSE 0 cookie-1 v$1
+
.example.com TRUE /foo/ FALSE 0 cookie-2 v$2
+
example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3
+
example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4
+
example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5
+
#HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6
+
#HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
+4
test/dune
···
···
+
(test
+
(name test_cookeio)
+
(libraries cookeio alcotest eio eio.unix eio_main ptime)
+
(deps cookies.txt))
+251
test/test_cookeio.ml
···
···
+
open Cookeio
+
+
let cookie_testable : Cookeio.t Alcotest.testable =
+
Alcotest.testable
+
(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)
+
(Format.pp_print_option Ptime.pp)
+
(Cookeio.expires 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.http_only c1 = Cookeio.http_only c2
+
&& Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
+
&& Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
+
+
let test_load_mozilla_cookies () =
+
let content =
+
{|# Netscape HTTP Cookie File
+
# http://curl.haxx.se/rfc/cookie_spec.html
+
# This is a generated file! Do not edit.
+
+
example.com FALSE /foo/ FALSE 0 cookie-1 v$1
+
.example.com TRUE /foo/ FALSE 0 cookie-2 v$2
+
example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3
+
example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4
+
example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5
+
#HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6
+
#HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
+
|}
+
in
+
let jar = from_mozilla_format content in
+
let cookies = get_all_cookies jar in
+
+
(* Check total number of cookies (should skip commented lines) *)
+
Alcotest.(check int) "cookie count" 5 (List.length cookies);
+
Alcotest.(check int) "count function" 5 (count jar);
+
Alcotest.(check bool) "not empty" false (is_empty jar);
+
+
let find_cookie name = List.find (fun c -> Cookeio.name c = name) cookies in
+
+
(* 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 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);
+
Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1);
+
Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1);
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"cookie-1 expires" None (Cookeio.expires cookie1);
+
Alcotest.(
+
check
+
(option
+
(Alcotest.testable
+
(fun ppf -> function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None")
+
( = ))))
+
"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 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);
+
Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2);
+
Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2);
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"cookie-2 expires" None (Cookeio.expires 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 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);
+
Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3);
+
Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3);
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"cookie-3 expires" expected_expiry (Cookeio.expires 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 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);
+
Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4);
+
Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4);
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"cookie-4 expires" expected_expiry (Cookeio.expires 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 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);
+
Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5);
+
Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5);
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"cookie-5 expires" expected_expiry (Cookeio.expires cookie5)
+
+
let test_load_from_file env =
+
(* This test loads from the actual test/cookies.txt file using the load function *)
+
let cwd = Eio.Stdenv.cwd env in
+
let cookie_path = Eio.Path.(cwd / "cookies.txt") in
+
let jar = load cookie_path in
+
let cookies = get_all_cookies jar in
+
+
(* Should have the same 5 cookies as the string test *)
+
Alcotest.(check int) "file load cookie count" 5 (List.length cookies);
+
+
let find_cookie name = List.find (fun c -> Cookeio.name c = name) cookies in
+
+
(* 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 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);
+
+
let cookie5 = find_cookie "cookie-5" in
+
Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5);
+
Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5);
+
let expected_expiry = Ptime.of_float_s 1257894000.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"file cookie-5 expires" expected_expiry (Cookeio.expires cookie5);
+
+
(* Verify subdomain cookie *)
+
let cookie2 = find_cookie "cookie-2" in
+
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)
+
+
let test_cookie_matching () =
+
let jar = create () in
+
+
(* 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
+
~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 ()
+
in
+
let secure_cookie =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3"
+
~secure:true ~http_only:false ?expires:None ?same_site:None
+
~creation_time:Ptime.epoch ~last_access:Ptime.epoch ()
+
in
+
+
add_cookie jar exact_cookie;
+
add_cookie jar subdomain_cookie;
+
add_cookie jar secure_cookie;
+
+
(* Test exact domain matching *)
+
let cookies_http =
+
get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "http cookies count" 2 (List.length cookies_http);
+
+
let cookies_https =
+
get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:true
+
in
+
Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
+
+
(* Test subdomain matching *)
+
let cookies_sub =
+
get_cookies jar ~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)
+
+
let test_empty_jar () =
+
let jar = create () in
+
Alcotest.(check bool) "empty jar" true (is_empty jar);
+
Alcotest.(check int) "empty count" 0 (count jar);
+
Alcotest.(check (list cookie_testable))
+
"empty cookies" [] (get_all_cookies jar);
+
+
let cookies =
+
get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
Alcotest.(check int) "no matching cookies" 0 (List.length cookies)
+
+
let test_round_trip_mozilla_format () =
+
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 ()
+
in
+
+
add_cookie jar test_cookie;
+
+
(* Convert to Mozilla format and back *)
+
let mozilla_format = to_mozilla_format jar in
+
let jar2 = from_mozilla_format mozilla_format in
+
let cookies2 = get_all_cookies jar2 in
+
+
Alcotest.(check int) "round trip count" 1 (List.length cookies2);
+
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 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 *)
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"round trip expires"
+
(Ptime.of_float_s 1257894000.0)
+
(Cookeio.expires cookie2)
+
+
let () =
+
Eio_main.run @@ fun env ->
+
let open Alcotest in
+
run "Cookeio Tests"
+
[
+
( "mozilla_format",
+
[
+
test_case "Load Mozilla format from string" `Quick
+
test_load_mozilla_cookies;
+
test_case "Load Mozilla format from file" `Quick (fun () ->
+
test_load_from_file env);
+
test_case "Round trip Mozilla format" `Quick
+
test_round_trip_mozilla_format;
+
] );
+
( "cookie_matching",
+
[ test_case "Domain and security matching" `Quick test_cookie_matching ]
+
);
+
( "basic_operations",
+
[ test_case "Empty jar operations" `Quick test_empty_jar ] );
+
]