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

update metadata and tests

+16 -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.
-
*
-
*)
+
+
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.
+5 -4
cookeio.opam
···
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"]
+
maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
authors: ["Anil Madhavapeddy"]
license: "ISC"
-
homepage: "https://github.com/avsm/cookeio"
-
bug-reports: "https://github.com/avsm/cookeio/issues"
+
homepage: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio"
+
bug-reports: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio/issues"
depends: [
"ocaml" {>= "5.2.0"}
-
"dune" {>= "3.19"}
+
"dune" {>= "3.20"}
"eio" {>= "1.0"}
"logs" {>= "0.9.0"}
"ptime" {>= "1.1.0"}
+
"eio_main" {with-test}
"alcotest" {with-test}
"odoc" {with-doc}
]
+7 -3
dune-project
···
-
(lang dune 3.19)
+
(lang dune 3.20)
(name cookeio)
···
(source (github avsm/cookeio))
+
(license ISC)
(authors "Anil Madhavapeddy")
-
(maintainers "Anil Madhavapeddy")
-
(license ISC)
+
(homepage "https://tangled.sh/@anil.recoil.org/ocaml-cookeio")
+
(maintainers "Anil Madhavapeddy <anil@recoil.org>")
+
(bug_reports "https://tangled.sh/@anil.recoil.org/ocaml-cookeio/issues")
+
(maintenance_intent "(latest)")
(package
(name cookeio)
···
(eio (>= 1.0))
(logs (>= 0.9.0))
(ptime (>= 1.1.0))
+
(eio_main :with-test)
(alcotest :with-test)))
+1 -1
test/dune
···
(test
(name test_cookeio)
-
(libraries cookeio alcotest eio eio.unix eio_main ptime)
+
(libraries cookeio alcotest eio eio.unix eio_main eio.mock ptime)
(deps cookies.txt))
+224
test/test_cookeio.ml
···
(Ptime.of_float_s 1257894000.0)
(Cookeio.expires cookie2)
+
let test_cookie_expiry_with_mock_clock () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at time 1000.0 for convenience *)
+
Eio_mock.Clock.set_time clock 1000.0;
+
+
let jar = create () in
+
+
(* Add a cookie that expires at time 1500.0 (expires in 500 seconds) *)
+
let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in
+
let cookie1 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon"
+
~value:"value1" ~secure:false ~http_only:false ~expires:expires_soon
+
?same_site:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
(* Add a cookie that expires at time 2000.0 (expires in 1000 seconds) *)
+
let expires_later = Ptime.of_float_s 2000.0 |> Option.get in
+
let cookie2 =
+
Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later"
+
~value:"value2" ~secure:false ~http_only:false ~expires:expires_later
+
?same_site:None
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
(* 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
+
~creation_time:(Ptime.of_float_s 1000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 1000.0 |> Option.get)
+
()
+
in
+
+
add_cookie jar cookie1;
+
add_cookie jar cookie2;
+
add_cookie jar cookie3;
+
+
Alcotest.(check int) "initial count" 3 (count jar);
+
+
(* Advance time to 1600.0 - first cookie should expire *)
+
Eio_mock.Clock.set_time clock 1600.0;
+
clear_expired jar ~clock;
+
+
Alcotest.(check int) "after first expiry" 2 (count jar);
+
+
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;
+
+
(* Advance time to 2100.0 - second cookie should expire *)
+
Eio_mock.Clock.set_time clock 2100.0;
+
clear_expired jar ~clock;
+
+
Alcotest.(check int) "after second expiry" 1 (count jar);
+
+
let remaining = get_all_cookies jar in
+
Alcotest.(check string) "only session cookie remains" "session"
+
(Cookeio.name (List.hd remaining))
+
+
let test_max_age_parsing_with_mock_clock () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at a known time *)
+
Eio_mock.Clock.set_time clock 5000.0;
+
+
(* Parse a Set-Cookie header with Max-Age *)
+
let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in
+
let cookie_opt =
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
in
+
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
+
let cookie = Option.get cookie_opt in
+
Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie);
+
Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie);
+
Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie);
+
Alcotest.(check bool) "cookie http_only" true (Cookeio.http_only cookie);
+
+
(* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *)
+
let expected_expiry = Ptime.of_float_s 8600.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires set from max-age" expected_expiry (Cookeio.expires cookie);
+
+
(* Verify creation time matches clock time *)
+
let expected_creation = Ptime.of_float_s 5000.0 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"creation time" expected_creation
+
(Some (Cookeio.creation_time cookie))
+
+
let test_last_access_time_with_mock_clock () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at time 3000.0 *)
+
Eio_mock.Clock.set_time clock 3000.0;
+
+
let jar = create () in
+
+
(* 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
+
~creation_time:(Ptime.of_float_s 3000.0 |> Option.get)
+
~last_access:(Ptime.of_float_s 3000.0 |> Option.get)
+
()
+
in
+
add_cookie jar cookie;
+
+
(* Verify initial last access time *)
+
let cookies1 = get_all_cookies jar in
+
let cookie1 = List.hd cookies1 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"initial last access" (Ptime.of_float_s 3000.0)
+
(Some (Cookeio.last_access cookie1));
+
+
(* Advance time to 4000.0 *)
+
Eio_mock.Clock.set_time clock 4000.0;
+
+
(* Get cookies, which should update last access time to current clock time *)
+
let _retrieved =
+
get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
+
in
+
+
(* Verify last access time was updated to the new clock time *)
+
let cookies2 = get_all_cookies jar in
+
let cookie2 = List.hd cookies2 in
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"updated last access" (Ptime.of_float_s 4000.0)
+
(Some (Cookeio.last_access cookie2))
+
+
let test_parse_set_cookie_with_expires () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at a known time *)
+
Eio_mock.Clock.set_time clock 6000.0;
+
+
(* Use RFC3339 format which is what Ptime.of_rfc3339 expects *)
+
let header =
+
"id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com"
+
in
+
let cookie_opt =
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header
+
in
+
+
Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt);
+
+
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 path" "/" (Cookeio.path cookie);
+
+
(* Verify expires is parsed correctly *)
+
Alcotest.(check bool) "has expiry" true
+
(Option.is_some (Cookeio.expires cookie));
+
+
(* Verify the specific expiry time parsed from the RFC3339 date *)
+
let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in
+
match expected_expiry with
+
| Ok (time, _, _) ->
+
Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
+
"expires matches parsed value" (Some time) (Cookeio.expires cookie)
+
| Error _ -> Alcotest.fail "Failed to parse expected expiry time"
+
+
let test_samesite_none_validation () =
+
Eio_mock.Backend.run @@ fun () ->
+
let clock = Eio_mock.Clock.make () in
+
+
(* Start at a known time *)
+
Eio_mock.Clock.set_time clock 7000.0;
+
+
(* This should be rejected: SameSite=None without Secure *)
+
let invalid_header = "token=abc; SameSite=None" in
+
let cookie_opt =
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" invalid_header
+
in
+
+
Alcotest.(check bool) "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
+
let cookie_opt2 =
+
parse_set_cookie ~clock ~domain:"example.com" ~path:"/" valid_header
+
in
+
+
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);
+
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")
+
( = ))))
+
"samesite is None" (Some `None) (Cookeio.same_site cookie)
+
let () =
Eio_main.run @@ fun env ->
let open Alcotest in
···
( "basic_operations",
[ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ]
);
+
( "time_handling",
+
[
+
test_case "Cookie expiry with mock clock" `Quick
+
test_cookie_expiry_with_mock_clock;
+
test_case "Max-Age parsing with mock clock" `Quick
+
test_max_age_parsing_with_mock_clock;
+
test_case "Last access time with mock clock" `Quick
+
test_last_access_time_with_mock_clock;
+
test_case "Parse Set-Cookie with Expires" `Quick
+
test_parse_set_cookie_with_expires;
+
test_case "SameSite=None validation" `Quick
+
test_samesite_none_validation;
+
] );
]