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

init import

+991
+2
.gitignore
··· 1 + _build 2 + .*.swp
+1
.ocamlformat
··· 1 + version=0.27.0
+18
LICENSE.md
··· 1 + (* 2 + * ISC License 3 + * 4 + * Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org> 5 + * 6 + * Permission to use, copy, modify, and distribute this software for any 7 + * purpose with or without fee is hereby granted, provided that the above 8 + * copyright notice and this permission notice appear in all copies. 9 + * 10 + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 + * 18 + *)
+64
README.md
··· 1 + # Cookeio - HTTP Cookie Management for OCaml 2 + 3 + Cookeio is an OCaml library for managing HTTP cookies. 4 + 5 + ## Overview 6 + 7 + HTTP cookies are a mechanism for maintaining client-side state in web 8 + applications. Originally specified to allow "server side connections to store 9 + and retrieve information on the client side," cookies enable persistent storage 10 + of user preferences, session data, shopping cart contents, and authentication 11 + tokens. 12 + 13 + This library provides a complete cookie jar implementation following 14 + established standards while integrating with OCaml's for efficient asynchronous 15 + operations. 16 + 17 + ## Cookie Attributes 18 + 19 + The library supports all standard HTTP cookie attributes: 20 + 21 + - **Domain**: Controls which domains can access the cookie using tail matching 22 + - **Path**: Defines URL subsets where the cookie is valid 23 + - **Secure**: Restricts transmission to HTTPS connections only 24 + - **HttpOnly**: Prevents JavaScript access to the cookie 25 + - **Expires**: Sets cookie lifetime (session cookies when omitted) 26 + - **SameSite**: Controls cross-site request behavior (`Strict`, `Lax`, or `None`) 27 + 28 + ## Usage 29 + 30 + ```ocaml 31 + (* Create a new cookie jar *) 32 + let jar = Cookeio.create () in 33 + 34 + (* Parse a Set-Cookie header *) 35 + let cookie = Cookeio.parse_set_cookie 36 + ~domain:"example.com" 37 + ~path:"/" 38 + "session=abc123; Secure; HttpOnly; SameSite=Strict" in 39 + 40 + (* Add cookie to jar *) 41 + Option.iter (Cookeio.add_cookie jar) cookie; 42 + 43 + (* Get cookies for a request *) 44 + let cookies = Cookeio.get_cookies jar 45 + ~domain:"example.com" 46 + ~path:"/api" 47 + ~is_secure:true in 48 + 49 + (* Generate Cookie header *) 50 + let header = Cookeio.make_cookie_header cookies 51 + ``` 52 + 53 + ## Storage and Persistence 54 + 55 + Cookies can be persisted to disk in Mozilla format for compatibility with other 56 + tools: 57 + 58 + ```ocaml 59 + (* Save cookies to file *) 60 + Cookeio.save (Eio.Path.of_string "cookies.txt") jar; 61 + 62 + (* Load cookies from file *) 63 + let jar = Cookeio.load (Eio.Path.of_string "cookies.txt") 64 + ```
+35
cookeio.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Cookie parsing and management library using Eio" 4 + description: 5 + "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." 6 + maintainer: ["Anil Madhavapeddy"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + homepage: "https://github.com/avsm/cookeio" 10 + bug-reports: "https://github.com/avsm/cookeio/issues" 11 + depends: [ 12 + "ocaml" {>= "5.2.0"} 13 + "dune" {>= "3.19"} 14 + "eio" {>= "1.0"} 15 + "logs" {>= "0.9.0"} 16 + "ptime" {>= "1.1.0"} 17 + "alcotest" {with-test} 18 + "odoc" {with-doc} 19 + ] 20 + build: [ 21 + ["dune" "subst"] {dev} 22 + [ 23 + "dune" 24 + "build" 25 + "-p" 26 + name 27 + "-j" 28 + jobs 29 + "@install" 30 + "@runtest" {with-test} 31 + "@doc" {with-doc} 32 + ] 33 + ] 34 + dev-repo: "git+https://github.com/avsm/cookeio.git" 35 + x-maintenance-intent: ["(latest)"]
+23
dune-project
··· 1 + (lang dune 3.19) 2 + 3 + (name cookeio) 4 + 5 + (generate_opam_files true) 6 + 7 + (source (github avsm/cookeio)) 8 + 9 + (authors "Anil Madhavapeddy") 10 + (maintainers "Anil Madhavapeddy") 11 + (license ISC) 12 + 13 + (package 14 + (name cookeio) 15 + (synopsis "Cookie parsing and management library using Eio") 16 + (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.") 17 + (depends 18 + (ocaml (>= 5.2.0)) 19 + dune 20 + (eio (>= 1.0)) 21 + (logs (>= 0.9.0)) 22 + (ptime (>= 1.1.0)) 23 + (alcotest :with-test)))
+397
lib/cookeio.ml
··· 1 + let src = Logs.Src.create "cookeio" ~doc:"Cookie management" 2 + 3 + module Log = (val Logs.src_log src : Logs.LOG) 4 + 5 + type same_site = [ `Strict | `Lax | `None ] 6 + (** Cookie same-site policy *) 7 + 8 + type t = { 9 + domain : string; 10 + path : string; 11 + name : string; 12 + value : string; 13 + secure : bool; 14 + http_only : bool; 15 + expires : Ptime.t option; 16 + same_site : same_site option; 17 + creation_time : Ptime.t; 18 + last_access : Ptime.t; 19 + } 20 + (** HTTP Cookie *) 21 + 22 + type jar = { mutable cookies : t list; mutex : Eio.Mutex.t } 23 + (** Cookie jar for storing and managing cookies *) 24 + 25 + (** {1 Cookie Accessors} *) 26 + 27 + let domain cookie = cookie.domain 28 + let path cookie = cookie.path 29 + let name cookie = cookie.name 30 + let value cookie = cookie.value 31 + let secure cookie = cookie.secure 32 + let http_only cookie = cookie.http_only 33 + let expires cookie = cookie.expires 34 + let same_site cookie = cookie.same_site 35 + let creation_time cookie = cookie.creation_time 36 + let last_access cookie = cookie.last_access 37 + 38 + let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false) 39 + ?expires ?same_site ~creation_time ~last_access () = 40 + { domain; path; name; value; secure; http_only; expires; same_site; creation_time; last_access } 41 + 42 + (** {1 Cookie Jar Creation} *) 43 + 44 + let create () = 45 + Log.debug (fun m -> m "Creating new empty cookie jar"); 46 + { cookies = []; mutex = Eio.Mutex.create () } 47 + 48 + (** {1 Cookie Matching Helpers} *) 49 + 50 + let domain_matches cookie_domain request_domain = 51 + (* Cookie domain .example.com matches example.com and sub.example.com *) 52 + if String.starts_with ~prefix:"." cookie_domain then 53 + let domain_suffix = String.sub cookie_domain 1 (String.length cookie_domain - 1) in 54 + request_domain = domain_suffix 55 + || String.ends_with ~suffix:("." ^ domain_suffix) request_domain 56 + else cookie_domain = request_domain 57 + 58 + let path_matches cookie_path request_path = 59 + (* Cookie path /foo matches /foo, /foo/, /foo/bar *) 60 + String.starts_with ~prefix:cookie_path request_path 61 + 62 + let is_expired cookie clock = 63 + match cookie.expires with 64 + | None -> false (* Session cookie *) 65 + | Some exp_time -> 66 + let now = 67 + Ptime.of_float_s (Eio.Time.now clock) 68 + |> Option.value ~default:Ptime.epoch 69 + in 70 + Ptime.compare now exp_time > 0 71 + 72 + (** {1 Cookie Parsing} *) 73 + 74 + let parse_cookie_attribute attr attr_value cookie = 75 + let attr_lower = String.lowercase_ascii attr in 76 + match attr_lower with 77 + | "domain" -> make ~domain:attr_value ~path:(path cookie) ~name:(name cookie) 78 + ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 79 + ?expires:(expires cookie) ?same_site:(same_site cookie) 80 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 81 + | "path" -> make ~domain:(domain cookie) ~path:attr_value ~name:(name cookie) 82 + ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 83 + ?expires:(expires cookie) ?same_site:(same_site cookie) 84 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 85 + | "expires" -> ( 86 + try 87 + let time, _tz_offset, _tz_string = 88 + Ptime.of_rfc3339 attr_value |> Result.get_ok 89 + in 90 + make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 91 + ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 92 + ~expires:time ?same_site:(same_site cookie) 93 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 94 + with _ -> 95 + Log.debug (fun m -> m "Failed to parse expires: %s" attr_value); 96 + cookie) 97 + | "max-age" -> ( 98 + try 99 + let seconds = int_of_string attr_value in 100 + let now = Unix.time () in 101 + let expires = Ptime.of_float_s (now +. float_of_int seconds) in 102 + make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 103 + ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 104 + ?expires ?same_site:(same_site cookie) 105 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 106 + with _ -> cookie) 107 + | "secure" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 108 + ~value:(value cookie) ~secure:true ~http_only:(http_only cookie) 109 + ?expires:(expires cookie) ?same_site:(same_site cookie) 110 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 111 + | "httponly" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 112 + ~value:(value cookie) ~secure:(secure cookie) ~http_only:true 113 + ?expires:(expires cookie) ?same_site:(same_site cookie) 114 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 115 + | "samesite" -> 116 + let same_site_val = 117 + match String.lowercase_ascii attr_value with 118 + | "strict" -> Some `Strict 119 + | "lax" -> Some `Lax 120 + | "none" -> Some `None 121 + | _ -> None 122 + in 123 + make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 124 + ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 125 + ?expires:(expires cookie) ?same_site:same_site_val 126 + ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 127 + | _ -> cookie 128 + 129 + let rec parse_set_cookie ~domain:request_domain ~path:request_path header_value = 130 + Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value); 131 + 132 + (* Split into attributes *) 133 + let parts = String.split_on_char ';' header_value |> List.map String.trim in 134 + 135 + match parts with 136 + | [] -> None 137 + | name_value :: attrs -> ( 138 + (* Parse name=value *) 139 + match String.index_opt name_value '=' with 140 + | None -> None 141 + | Some eq_pos -> 142 + let name = String.sub name_value 0 eq_pos |> String.trim in 143 + let cookie_value = 144 + String.sub name_value (eq_pos + 1) 145 + (String.length name_value - eq_pos - 1) 146 + |> String.trim 147 + in 148 + 149 + let now = 150 + Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch 151 + in 152 + let base_cookie = 153 + make ~domain:request_domain ~path:request_path ~name ~value:cookie_value ~secure:false ~http_only:false 154 + ?expires:None ?same_site:None ~creation_time:now ~last_access:now () 155 + in 156 + 157 + (* Parse attributes *) 158 + let cookie = 159 + List.fold_left 160 + (fun cookie attr -> 161 + match String.index_opt attr '=' with 162 + | None -> parse_cookie_attribute attr "" cookie 163 + | Some eq -> 164 + let attr_name = String.sub attr 0 eq |> String.trim in 165 + let attr_value = 166 + String.sub attr (eq + 1) (String.length attr - eq - 1) 167 + |> String.trim 168 + in 169 + parse_cookie_attribute attr_name attr_value cookie) 170 + base_cookie attrs 171 + in 172 + 173 + Log.debug (fun m -> m "Parsed cookie: %a" pp cookie); 174 + Some cookie) 175 + 176 + and make_cookie_header cookies = 177 + cookies 178 + |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c)) 179 + |> String.concat "; " 180 + 181 + (** {1 Pretty Printing} *) 182 + 183 + and pp_same_site ppf = function 184 + | `Strict -> Format.pp_print_string ppf "Strict" 185 + | `Lax -> Format.pp_print_string ppf "Lax" 186 + | `None -> Format.pp_print_string ppf "None" 187 + 188 + and pp ppf cookie = 189 + Format.fprintf ppf 190 + "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \ 191 + http_only=%b;@ expires=%a;@ same_site=%a }@]" 192 + (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie) 193 + (http_only cookie) 194 + (Format.pp_print_option Ptime.pp) 195 + (expires cookie) 196 + (Format.pp_print_option pp_same_site) 197 + (same_site cookie) 198 + 199 + let pp_jar ppf jar = 200 + Eio.Mutex.lock jar.mutex; 201 + let cookies = jar.cookies in 202 + Eio.Mutex.unlock jar.mutex; 203 + 204 + Format.fprintf ppf "@[<v>CookieJar with %d cookies:@," (List.length cookies); 205 + List.iter (fun cookie -> Format.fprintf ppf " %a@," pp cookie) cookies; 206 + Format.fprintf ppf "@]" 207 + 208 + (** {1 Cookie Management} *) 209 + 210 + let add_cookie jar cookie = 211 + Log.debug (fun m -> 212 + m "Adding cookie: %s=%s for domain %s" (name cookie) (value cookie) 213 + (domain cookie)); 214 + 215 + Eio.Mutex.lock jar.mutex; 216 + (* Remove existing cookie with same name, domain, and path *) 217 + jar.cookies <- 218 + List.filter 219 + (fun c -> 220 + not 221 + (name c = name cookie && domain c = domain cookie 222 + && path c = path cookie)) 223 + jar.cookies; 224 + jar.cookies <- cookie :: jar.cookies; 225 + Eio.Mutex.unlock jar.mutex 226 + 227 + let get_cookies jar ~domain:request_domain ~path:request_path ~is_secure = 228 + Log.debug (fun m -> 229 + m "Getting cookies for domain=%s path=%s secure=%b" request_domain request_path is_secure); 230 + 231 + Eio.Mutex.lock jar.mutex; 232 + let applicable = 233 + List.filter 234 + (fun cookie -> 235 + domain_matches (domain cookie) request_domain 236 + && path_matches (path cookie) request_path 237 + && ((not (secure cookie)) || is_secure)) 238 + jar.cookies 239 + in 240 + 241 + (* Update last access time *) 242 + let now = 243 + Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch 244 + in 245 + let updated = 246 + List.map 247 + (fun c -> 248 + if List.memq c applicable then 249 + make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c) 250 + ~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c) 251 + ?same_site:(same_site c) ~creation_time:(creation_time c) ~last_access:now () 252 + else c) 253 + jar.cookies 254 + in 255 + jar.cookies <- updated; 256 + Eio.Mutex.unlock jar.mutex; 257 + 258 + Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable)); 259 + applicable 260 + 261 + let clear jar = 262 + Log.info (fun m -> m "Clearing all cookies"); 263 + Eio.Mutex.lock jar.mutex; 264 + jar.cookies <- []; 265 + Eio.Mutex.unlock jar.mutex 266 + 267 + let clear_expired jar ~clock = 268 + Eio.Mutex.lock jar.mutex; 269 + let before_count = List.length jar.cookies in 270 + jar.cookies <- List.filter (fun c -> not (is_expired c clock)) jar.cookies; 271 + let removed = before_count - List.length jar.cookies in 272 + Eio.Mutex.unlock jar.mutex; 273 + Log.info (fun m -> m "Cleared %d expired cookies" removed) 274 + 275 + let clear_session_cookies jar = 276 + Eio.Mutex.lock jar.mutex; 277 + let before_count = List.length jar.cookies in 278 + jar.cookies <- List.filter (fun c -> expires c <> None) jar.cookies; 279 + let removed = before_count - List.length jar.cookies in 280 + Eio.Mutex.unlock jar.mutex; 281 + Log.info (fun m -> m "Cleared %d session cookies" removed) 282 + 283 + let count jar = 284 + Eio.Mutex.lock jar.mutex; 285 + let n = List.length jar.cookies in 286 + Eio.Mutex.unlock jar.mutex; 287 + n 288 + 289 + let get_all_cookies jar = 290 + Eio.Mutex.lock jar.mutex; 291 + let cookies = jar.cookies in 292 + Eio.Mutex.unlock jar.mutex; 293 + cookies 294 + 295 + let is_empty jar = 296 + Eio.Mutex.lock jar.mutex; 297 + let empty = jar.cookies = [] in 298 + Eio.Mutex.unlock jar.mutex; 299 + empty 300 + 301 + (** {1 Mozilla Format} *) 302 + 303 + let to_mozilla_format_internal jar = 304 + let buffer = Buffer.create 1024 in 305 + Buffer.add_string buffer "# Netscape HTTP Cookie File\n"; 306 + Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n"; 307 + 308 + List.iter 309 + (fun cookie -> 310 + let include_subdomains = 311 + if String.starts_with ~prefix:"." (domain cookie) then "TRUE" else "FALSE" 312 + in 313 + let secure_flag = if secure cookie then "TRUE" else "FALSE" in 314 + let expires_str = 315 + match expires cookie with 316 + | None -> "0" (* Session cookie *) 317 + | Some t -> 318 + let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in 319 + epoch 320 + in 321 + 322 + Buffer.add_string buffer 323 + (Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (domain cookie) 324 + include_subdomains (path cookie) secure_flag expires_str (name cookie) 325 + (value cookie))) 326 + jar.cookies; 327 + 328 + Buffer.contents buffer 329 + 330 + let to_mozilla_format jar = 331 + Eio.Mutex.lock jar.mutex; 332 + let result = to_mozilla_format_internal jar in 333 + Eio.Mutex.unlock jar.mutex; 334 + result 335 + 336 + let from_mozilla_format content = 337 + Log.debug (fun m -> m "Parsing Mozilla format cookies"); 338 + let jar = create () in 339 + 340 + let lines = String.split_on_char '\n' content in 341 + List.iter 342 + (fun line -> 343 + let line = String.trim line in 344 + if line <> "" && not (String.starts_with ~prefix:"#" line) then 345 + match String.split_on_char '\t' line with 346 + | [ domain; _include_subdomains; path; secure; expires; name; value ] -> 347 + let now = 348 + Ptime.of_float_s (Unix.time ()) 349 + |> Option.value ~default:Ptime.epoch 350 + in 351 + let expires = 352 + let exp_int = try int_of_string expires with _ -> 0 in 353 + if exp_int = 0 then None 354 + else Ptime.of_float_s (float_of_int exp_int) 355 + in 356 + 357 + let cookie = 358 + make ~domain ~path ~name ~value 359 + ~secure:(secure = "TRUE") ~http_only:false 360 + ?expires ?same_site:None 361 + ~creation_time:now ~last_access:now () 362 + in 363 + add_cookie jar cookie; 364 + Log.debug (fun m -> m "Loaded cookie: %s=%s" name value) 365 + | _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line)) 366 + lines; 367 + 368 + Log.info (fun m -> m "Loaded %d cookies" (List.length jar.cookies)); 369 + jar 370 + 371 + (** {1 File Operations} *) 372 + 373 + let load path = 374 + Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path); 375 + 376 + try 377 + let content = Eio.Path.load path in 378 + from_mozilla_format content 379 + with 380 + | Eio.Io _ -> 381 + Log.info (fun m -> m "Cookie file not found, creating empty jar"); 382 + create () 383 + | exn -> 384 + Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn)); 385 + create () 386 + 387 + let save path jar = 388 + Log.info (fun m -> 389 + m "Saving %d cookies to %a" (List.length jar.cookies) Eio.Path.pp path); 390 + 391 + let content = to_mozilla_format jar in 392 + 393 + try 394 + Eio.Path.save ~create:(`Or_truncate 0o600) path content; 395 + Log.debug (fun m -> m "Cookies saved successfully") 396 + with exn -> 397 + Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+181
lib/cookeio.mli
··· 1 + (** Cookie management library for OCaml 2 + 3 + HTTP cookies are a mechanism that allows "server side 4 + connections to store and retrieve information on the client side." 5 + Originally designed to enable persistent client-side state for web 6 + applications, cookies are essential for storing user preferences, session 7 + data, shopping cart contents, and authentication tokens. 8 + 9 + This library provides a complete cookie jar implementation following 10 + established web standards while integrating Eio for efficient asynchronous operations. 11 + 12 + {2 Cookie Format and Structure} 13 + 14 + Cookies are set via the Set-Cookie HTTP response header with the basic 15 + format: [NAME=VALUE] with optional attributes including: 16 + - [expires]: Optional cookie lifetime specification 17 + - [domain]: Specifying valid domains using tail matching 18 + - [path]: Defining URL subset for cookie validity 19 + - [secure]: Transmission over secure channels only 20 + - [httponly]: Not accessible to JavaScript 21 + - [samesite]: Cross-site request behavior control 22 + 23 + {2 Domain and Path Matching} 24 + 25 + The library implements standard domain and path matching rules: 26 + - Domain matching uses "tail matching" (e.g., "acme.com" matches 27 + "anvil.acme.com") 28 + - Path matching allows subset URL specification for fine-grained control 29 + - More specific path mappings are sent first in Cookie headers 30 + 31 + *) 32 + 33 + type same_site = [ `Strict | `Lax | `None ] 34 + (** Cookie same-site policy for controlling cross-site request behavior. 35 + 36 + - [`Strict]: Cookie only sent for same-site requests, providing maximum 37 + protection 38 + - [`Lax]: Cookie sent for same-site requests and top-level navigation 39 + (default for modern browsers) 40 + - [`None]: Cookie sent for all cross-site requests (requires [secure] flag) 41 + *) 42 + 43 + type t 44 + (** HTTP Cookie representation with all standard attributes. 45 + 46 + A cookie represents a name-value pair with associated metadata that controls 47 + its scope, security, and lifetime. Cookies with the same [name], [domain], 48 + and [path] will overwrite each other when added to a cookie jar. *) 49 + 50 + type jar 51 + (** Cookie jar for storing and managing cookies. 52 + 53 + A cookie jar maintains a collection of cookies with automatic cleanup of 54 + expired entries and enforcement of storage limits. It implements the 55 + standard browser behavior for cookie storage, including: 56 + - Automatic removal of expired cookies 57 + - LRU eviction when storage limits are exceeded 58 + - Domain and path-based cookie retrieval 59 + - Mozilla format persistence for cross-tool compatibility *) 60 + 61 + (** {1 Cookie Accessors} *) 62 + 63 + val domain : t -> string 64 + (** Get the domain of a cookie *) 65 + 66 + val path : t -> string 67 + (** Get the path of a cookie *) 68 + 69 + val name : t -> string 70 + (** Get the name of a cookie *) 71 + 72 + val value : t -> string 73 + (** Get the value of a cookie *) 74 + 75 + val secure : t -> bool 76 + (** Check if cookie is secure only *) 77 + 78 + val http_only : t -> bool 79 + (** Check if cookie is HTTP only *) 80 + 81 + val expires : t -> Ptime.t option 82 + (** Get the expiry time of a cookie *) 83 + 84 + val same_site : t -> same_site option 85 + (** Get the same-site policy of a cookie *) 86 + 87 + val creation_time : t -> Ptime.t 88 + (** Get the creation time of a cookie *) 89 + 90 + val last_access : t -> Ptime.t 91 + (** Get the last access time of a cookie *) 92 + 93 + val make : domain:string -> path:string -> name:string -> value:string -> 94 + ?secure:bool -> ?http_only:bool -> ?expires:Ptime.t -> 95 + ?same_site:same_site -> creation_time:Ptime.t -> last_access:Ptime.t -> 96 + unit -> t 97 + (** Create a new cookie with the given attributes *) 98 + 99 + (** {1 Cookie Jar Creation and Loading} *) 100 + 101 + val create : unit -> jar 102 + (** Create an empty cookie jar *) 103 + 104 + val load : Eio.Fs.dir_ty Eio.Path.t -> jar 105 + (** Load cookies from Mozilla format file *) 106 + 107 + val save : Eio.Fs.dir_ty Eio.Path.t -> jar -> unit 108 + (** Save cookies to Mozilla format file *) 109 + 110 + (** {1 Cookie Jar Management} *) 111 + 112 + val add_cookie : jar -> t -> unit 113 + (** Add a cookie to the jar *) 114 + 115 + val get_cookies : 116 + jar -> domain:string -> path:string -> is_secure:bool -> t list 117 + (** Get cookies applicable for a URL *) 118 + 119 + val clear : jar -> unit 120 + (** Clear all cookies *) 121 + 122 + val clear_expired : jar -> clock:_ Eio.Time.clock -> unit 123 + (** Clear expired cookies *) 124 + 125 + val clear_session_cookies : jar -> unit 126 + (** Clear session cookies (those without expiry) *) 127 + 128 + val count : jar -> int 129 + (** Get the number of cookies in the jar *) 130 + 131 + val get_all_cookies : jar -> t list 132 + (** Get all cookies in the jar *) 133 + 134 + val is_empty : jar -> bool 135 + (** Check if the jar is empty *) 136 + 137 + (** {1 Cookie Creation and Parsing} *) 138 + 139 + val parse_set_cookie : domain:string -> path:string -> string -> t option 140 + (** Parse Set-Cookie header value into a cookie. 141 + 142 + Parses a Set-Cookie header value following RFC specifications: 143 + - Basic format: [NAME=VALUE; attribute1; attribute2=value2] 144 + - Supports all standard attributes: [expires], [domain], [path], [secure], 145 + [httponly], [samesite] 146 + - Returns [None] if parsing fails or cookie is invalid 147 + - The [domain] and [path] parameters provide the request context for default 148 + values 149 + 150 + Example: 151 + [parse_set_cookie ~domain:"example.com" ~path:"/" "session=abc123; Secure; 152 + HttpOnly"] *) 153 + 154 + val make_cookie_header : t list -> string 155 + (** Create cookie header value from cookies. 156 + 157 + Formats a list of cookies into a Cookie header value suitable for HTTP 158 + requests. 159 + - Format: [name1=value1; name2=value2; name3=value3] 160 + - Only includes cookie names and values, not attributes 161 + - Cookies should already be filtered for the target domain/path 162 + - More specific path mappings should be ordered first in the input list 163 + 164 + Example: [make_cookie_header cookies] might return 165 + ["session=abc123; theme=dark"] *) 166 + 167 + (** {1 Pretty Printing} *) 168 + 169 + val pp : Format.formatter -> t -> unit 170 + (** Pretty print a cookie *) 171 + 172 + val pp_jar : Format.formatter -> jar -> unit 173 + (** Pretty print a cookie jar *) 174 + 175 + (** {1 Mozilla Format} *) 176 + 177 + val to_mozilla_format : jar -> string 178 + (** Write cookies in Mozilla format *) 179 + 180 + val from_mozilla_format : string -> jar 181 + (** Parse Mozilla format cookies *)
+4
lib/dune
··· 1 + (library 2 + (name cookeio) 3 + (public_name cookeio) 4 + (libraries eio logs ptime unix))
+11
test/cookies.txt
··· 1 + # Netscape HTTP Cookie File 2 + # http://curl.haxx.se/rfc/cookie_spec.html 3 + # This is a generated file! Do not edit. 4 + 5 + example.com FALSE /foo/ FALSE 0 cookie-1 v$1 6 + .example.com TRUE /foo/ FALSE 0 cookie-2 v$2 7 + example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3 8 + example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4 9 + example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5 10 + #HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6 11 + #HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
+4
test/dune
··· 1 + (test 2 + (name test_cookeio) 3 + (libraries cookeio alcotest eio eio.unix eio_main ptime) 4 + (deps cookies.txt))
+251
test/test_cookeio.ml
··· 1 + open Cookeio 2 + 3 + let cookie_testable : Cookeio.t Alcotest.testable = 4 + Alcotest.testable 5 + (fun ppf c -> 6 + Format.fprintf ppf 7 + "{ name=%S; value=%S; domain=%S; path=%S; secure=%b; http_only=%b; \ 8 + expires=%a; same_site=%a }" 9 + (Cookeio.name c) (Cookeio.value c) (Cookeio.domain c) (Cookeio.path c) (Cookeio.secure c) (Cookeio.http_only c) 10 + (Format.pp_print_option Ptime.pp) 11 + (Cookeio.expires c) 12 + (Format.pp_print_option (fun ppf -> function 13 + | `Strict -> Format.pp_print_string ppf "Strict" 14 + | `Lax -> Format.pp_print_string ppf "Lax" 15 + | `None -> Format.pp_print_string ppf "None")) 16 + (Cookeio.same_site c)) 17 + (fun c1 c2 -> 18 + Cookeio.name c1 = Cookeio.name c2 && Cookeio.value c1 = Cookeio.value c2 && Cookeio.domain c1 = Cookeio.domain c2 19 + && Cookeio.path c1 = Cookeio.path c2 && Cookeio.secure c1 = Cookeio.secure c2 20 + && Cookeio.http_only c1 = Cookeio.http_only c2 21 + && Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2) 22 + && Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2)) 23 + 24 + let test_load_mozilla_cookies () = 25 + let content = 26 + {|# Netscape HTTP Cookie File 27 + # http://curl.haxx.se/rfc/cookie_spec.html 28 + # This is a generated file! Do not edit. 29 + 30 + example.com FALSE /foo/ FALSE 0 cookie-1 v$1 31 + .example.com TRUE /foo/ FALSE 0 cookie-2 v$2 32 + example.com FALSE /foo/ FALSE 1257894000 cookie-3 v$3 33 + example.com FALSE /foo/ FALSE 1257894000 cookie-4 v$4 34 + example.com FALSE /foo/ TRUE 1257894000 cookie-5 v$5 35 + #HttpOnly_example.com FALSE /foo/ FALSE 1257894000 cookie-6 v$6 36 + #HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7 37 + |} 38 + in 39 + let jar = from_mozilla_format content in 40 + let cookies = get_all_cookies jar in 41 + 42 + (* Check total number of cookies (should skip commented lines) *) 43 + Alcotest.(check int) "cookie count" 5 (List.length cookies); 44 + Alcotest.(check int) "count function" 5 (count jar); 45 + Alcotest.(check bool) "not empty" false (is_empty jar); 46 + 47 + let find_cookie name = List.find (fun c -> Cookeio.name c = name) cookies in 48 + 49 + (* Test cookie-1: session cookie on exact domain *) 50 + let cookie1 = find_cookie "cookie-1" in 51 + Alcotest.(check string) "cookie-1 domain" "example.com" (Cookeio.domain cookie1); 52 + Alcotest.(check string) "cookie-1 path" "/foo/" (Cookeio.path cookie1); 53 + Alcotest.(check string) "cookie-1 name" "cookie-1" (Cookeio.name cookie1); 54 + Alcotest.(check string) "cookie-1 value" "v$1" (Cookeio.value cookie1); 55 + Alcotest.(check bool) "cookie-1 secure" false (Cookeio.secure cookie1); 56 + Alcotest.(check bool) "cookie-1 http_only" false (Cookeio.http_only cookie1); 57 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 58 + "cookie-1 expires" None (Cookeio.expires cookie1); 59 + Alcotest.( 60 + check 61 + (option 62 + (Alcotest.testable 63 + (fun ppf -> function 64 + | `Strict -> Format.pp_print_string ppf "Strict" 65 + | `Lax -> Format.pp_print_string ppf "Lax" 66 + | `None -> Format.pp_print_string ppf "None") 67 + ( = )))) 68 + "cookie-1 same_site" None (Cookeio.same_site cookie1); 69 + 70 + (* Test cookie-2: session cookie on subdomain pattern *) 71 + let cookie2 = find_cookie "cookie-2" in 72 + Alcotest.(check string) "cookie-2 domain" ".example.com" (Cookeio.domain cookie2); 73 + Alcotest.(check string) "cookie-2 path" "/foo/" (Cookeio.path cookie2); 74 + Alcotest.(check string) "cookie-2 name" "cookie-2" (Cookeio.name cookie2); 75 + Alcotest.(check string) "cookie-2 value" "v$2" (Cookeio.value cookie2); 76 + Alcotest.(check bool) "cookie-2 secure" false (Cookeio.secure cookie2); 77 + Alcotest.(check bool) "cookie-2 http_only" false (Cookeio.http_only cookie2); 78 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 79 + "cookie-2 expires" None (Cookeio.expires cookie2); 80 + 81 + (* Test cookie-3: non-session cookie with expiry *) 82 + let cookie3 = find_cookie "cookie-3" in 83 + let expected_expiry = Ptime.of_float_s 1257894000.0 in 84 + Alcotest.(check string) "cookie-3 domain" "example.com" (Cookeio.domain cookie3); 85 + Alcotest.(check string) "cookie-3 path" "/foo/" (Cookeio.path cookie3); 86 + Alcotest.(check string) "cookie-3 name" "cookie-3" (Cookeio.name cookie3); 87 + Alcotest.(check string) "cookie-3 value" "v$3" (Cookeio.value cookie3); 88 + Alcotest.(check bool) "cookie-3 secure" false (Cookeio.secure cookie3); 89 + Alcotest.(check bool) "cookie-3 http_only" false (Cookeio.http_only cookie3); 90 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 91 + "cookie-3 expires" expected_expiry (Cookeio.expires cookie3); 92 + 93 + (* Test cookie-4: another non-session cookie *) 94 + let cookie4 = find_cookie "cookie-4" in 95 + Alcotest.(check string) "cookie-4 domain" "example.com" (Cookeio.domain cookie4); 96 + Alcotest.(check string) "cookie-4 path" "/foo/" (Cookeio.path cookie4); 97 + Alcotest.(check string) "cookie-4 name" "cookie-4" (Cookeio.name cookie4); 98 + Alcotest.(check string) "cookie-4 value" "v$4" (Cookeio.value cookie4); 99 + Alcotest.(check bool) "cookie-4 secure" false (Cookeio.secure cookie4); 100 + Alcotest.(check bool) "cookie-4 http_only" false (Cookeio.http_only cookie4); 101 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 102 + "cookie-4 expires" expected_expiry (Cookeio.expires cookie4); 103 + 104 + (* Test cookie-5: secure cookie *) 105 + let cookie5 = find_cookie "cookie-5" in 106 + Alcotest.(check string) "cookie-5 domain" "example.com" (Cookeio.domain cookie5); 107 + Alcotest.(check string) "cookie-5 path" "/foo/" (Cookeio.path cookie5); 108 + Alcotest.(check string) "cookie-5 name" "cookie-5" (Cookeio.name cookie5); 109 + Alcotest.(check string) "cookie-5 value" "v$5" (Cookeio.value cookie5); 110 + Alcotest.(check bool) "cookie-5 secure" true (Cookeio.secure cookie5); 111 + Alcotest.(check bool) "cookie-5 http_only" false (Cookeio.http_only cookie5); 112 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 113 + "cookie-5 expires" expected_expiry (Cookeio.expires cookie5) 114 + 115 + let test_load_from_file env = 116 + (* This test loads from the actual test/cookies.txt file using the load function *) 117 + let cwd = Eio.Stdenv.cwd env in 118 + let cookie_path = Eio.Path.(cwd / "cookies.txt") in 119 + let jar = load cookie_path in 120 + let cookies = get_all_cookies jar in 121 + 122 + (* Should have the same 5 cookies as the string test *) 123 + Alcotest.(check int) "file load cookie count" 5 (List.length cookies); 124 + 125 + let find_cookie name = List.find (fun c -> Cookeio.name c = name) cookies in 126 + 127 + (* Verify a few key cookies are loaded correctly *) 128 + let cookie1 = find_cookie "cookie-1" in 129 + Alcotest.(check string) "file cookie-1 value" "v$1" (Cookeio.value cookie1); 130 + Alcotest.(check string) "file cookie-1 domain" "example.com" (Cookeio.domain cookie1); 131 + Alcotest.(check bool) "file cookie-1 secure" false (Cookeio.secure cookie1); 132 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 133 + "file cookie-1 expires" None (Cookeio.expires cookie1); 134 + 135 + let cookie5 = find_cookie "cookie-5" in 136 + Alcotest.(check string) "file cookie-5 value" "v$5" (Cookeio.value cookie5); 137 + Alcotest.(check bool) "file cookie-5 secure" true (Cookeio.secure cookie5); 138 + let expected_expiry = Ptime.of_float_s 1257894000.0 in 139 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 140 + "file cookie-5 expires" expected_expiry (Cookeio.expires cookie5); 141 + 142 + (* Verify subdomain cookie *) 143 + let cookie2 = find_cookie "cookie-2" in 144 + Alcotest.(check string) "file cookie-2 domain" ".example.com" (Cookeio.domain cookie2); 145 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 146 + "file cookie-2 expires" None (Cookeio.expires cookie2) 147 + 148 + let test_cookie_matching () = 149 + let jar = create () in 150 + 151 + (* Add test cookies with different domain patterns *) 152 + let exact_cookie = 153 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"exact" ~value:"test1" 154 + ~secure:false ~http_only:false ?expires:None ?same_site:None 155 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 156 + in 157 + let subdomain_cookie = 158 + Cookeio.make ~domain:".example.com" ~path:"/" ~name:"subdomain" ~value:"test2" 159 + ~secure:false ~http_only:false ?expires:None ?same_site:None 160 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 161 + in 162 + let secure_cookie = 163 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"secure" ~value:"test3" 164 + ~secure:true ~http_only:false ?expires:None ?same_site:None 165 + ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 166 + in 167 + 168 + add_cookie jar exact_cookie; 169 + add_cookie jar subdomain_cookie; 170 + add_cookie jar secure_cookie; 171 + 172 + (* Test exact domain matching *) 173 + let cookies_http = 174 + get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false 175 + in 176 + Alcotest.(check int) "http cookies count" 2 (List.length cookies_http); 177 + 178 + let cookies_https = 179 + get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:true 180 + in 181 + Alcotest.(check int) "https cookies count" 3 (List.length cookies_https); 182 + 183 + (* Test subdomain matching *) 184 + let cookies_sub = 185 + get_cookies jar ~domain:"sub.example.com" ~path:"/" ~is_secure:false 186 + in 187 + Alcotest.(check int) "subdomain cookies count" 1 (List.length cookies_sub); 188 + let sub_cookie = List.hd cookies_sub in 189 + Alcotest.(check string) "subdomain cookie name" "subdomain" (Cookeio.name sub_cookie) 190 + 191 + let test_empty_jar () = 192 + let jar = create () in 193 + Alcotest.(check bool) "empty jar" true (is_empty jar); 194 + Alcotest.(check int) "empty count" 0 (count jar); 195 + Alcotest.(check (list cookie_testable)) 196 + "empty cookies" [] (get_all_cookies jar); 197 + 198 + let cookies = 199 + get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false 200 + in 201 + Alcotest.(check int) "no matching cookies" 0 (List.length cookies) 202 + 203 + let test_round_trip_mozilla_format () = 204 + let jar = create () in 205 + 206 + let test_cookie = 207 + Cookeio.make ~domain:"example.com" ~path:"/test/" ~name:"test" ~value:"value" 208 + ~secure:true ~http_only:false ?expires:(Ptime.of_float_s 1257894000.0) 209 + ~same_site:`Strict ~creation_time:Ptime.epoch ~last_access:Ptime.epoch () 210 + in 211 + 212 + add_cookie jar test_cookie; 213 + 214 + (* Convert to Mozilla format and back *) 215 + let mozilla_format = to_mozilla_format jar in 216 + let jar2 = from_mozilla_format mozilla_format in 217 + let cookies2 = get_all_cookies jar2 in 218 + 219 + Alcotest.(check int) "round trip count" 1 (List.length cookies2); 220 + let cookie2 = List.hd cookies2 in 221 + Alcotest.(check string) "round trip name" "test" (Cookeio.name cookie2); 222 + Alcotest.(check string) "round trip value" "value" (Cookeio.value cookie2); 223 + Alcotest.(check string) "round trip domain" "example.com" (Cookeio.domain cookie2); 224 + Alcotest.(check string) "round trip path" "/test/" (Cookeio.path cookie2); 225 + Alcotest.(check bool) "round trip secure" true (Cookeio.secure cookie2); 226 + (* Note: http_only and same_site are lost in Mozilla format *) 227 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 228 + "round trip expires" 229 + (Ptime.of_float_s 1257894000.0) 230 + (Cookeio.expires cookie2) 231 + 232 + let () = 233 + Eio_main.run @@ fun env -> 234 + let open Alcotest in 235 + run "Cookeio Tests" 236 + [ 237 + ( "mozilla_format", 238 + [ 239 + test_case "Load Mozilla format from string" `Quick 240 + test_load_mozilla_cookies; 241 + test_case "Load Mozilla format from file" `Quick (fun () -> 242 + test_load_from_file env); 243 + test_case "Round trip Mozilla format" `Quick 244 + test_round_trip_mozilla_format; 245 + ] ); 246 + ( "cookie_matching", 247 + [ test_case "Domain and security matching" `Quick test_cookie_matching ] 248 + ); 249 + ( "basic_operations", 250 + [ test_case "Empty jar operations" `Quick test_empty_jar ] ); 251 + ]