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

update metadata and tests

+253 -26
+16 -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 - *) 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.
+5 -4
cookeio.opam
··· 3 3 synopsis: "Cookie parsing and management library using Eio" 4 4 description: 5 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"] 6 + maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 7 authors: ["Anil Madhavapeddy"] 8 8 license: "ISC" 9 - homepage: "https://github.com/avsm/cookeio" 10 - bug-reports: "https://github.com/avsm/cookeio/issues" 9 + homepage: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio" 10 + bug-reports: "https://tangled.sh/@anil.recoil.org/ocaml-cookeio/issues" 11 11 depends: [ 12 12 "ocaml" {>= "5.2.0"} 13 - "dune" {>= "3.19"} 13 + "dune" {>= "3.20"} 14 14 "eio" {>= "1.0"} 15 15 "logs" {>= "0.9.0"} 16 16 "ptime" {>= "1.1.0"} 17 + "eio_main" {with-test} 17 18 "alcotest" {with-test} 18 19 "odoc" {with-doc} 19 20 ]
+7 -3
dune-project
··· 1 - (lang dune 3.19) 1 + (lang dune 3.20) 2 2 3 3 (name cookeio) 4 4 ··· 6 6 7 7 (source (github avsm/cookeio)) 8 8 9 + (license ISC) 9 10 (authors "Anil Madhavapeddy") 10 - (maintainers "Anil Madhavapeddy") 11 - (license ISC) 11 + (homepage "https://tangled.sh/@anil.recoil.org/ocaml-cookeio") 12 + (maintainers "Anil Madhavapeddy <anil@recoil.org>") 13 + (bug_reports "https://tangled.sh/@anil.recoil.org/ocaml-cookeio/issues") 14 + (maintenance_intent "(latest)") 12 15 13 16 (package 14 17 (name cookeio) ··· 20 23 (eio (>= 1.0)) 21 24 (logs (>= 0.9.0)) 22 25 (ptime (>= 1.1.0)) 26 + (eio_main :with-test) 23 27 (alcotest :with-test)))
+1 -1
test/dune
··· 1 1 (test 2 2 (name test_cookeio) 3 - (libraries cookeio alcotest eio eio.unix eio_main ptime) 3 + (libraries cookeio alcotest eio eio.unix eio_main eio.mock ptime) 4 4 (deps cookies.txt))
+224
test/test_cookeio.ml
··· 234 234 (Ptime.of_float_s 1257894000.0) 235 235 (Cookeio.expires cookie2) 236 236 237 + let test_cookie_expiry_with_mock_clock () = 238 + Eio_mock.Backend.run @@ fun () -> 239 + let clock = Eio_mock.Clock.make () in 240 + 241 + (* Start at time 1000.0 for convenience *) 242 + Eio_mock.Clock.set_time clock 1000.0; 243 + 244 + let jar = create () in 245 + 246 + (* Add a cookie that expires at time 1500.0 (expires in 500 seconds) *) 247 + let expires_soon = Ptime.of_float_s 1500.0 |> Option.get in 248 + let cookie1 = 249 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_soon" 250 + ~value:"value1" ~secure:false ~http_only:false ~expires:expires_soon 251 + ?same_site:None 252 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 253 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 254 + () 255 + in 256 + 257 + (* Add a cookie that expires at time 2000.0 (expires in 1000 seconds) *) 258 + let expires_later = Ptime.of_float_s 2000.0 |> Option.get in 259 + let cookie2 = 260 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"expires_later" 261 + ~value:"value2" ~secure:false ~http_only:false ~expires:expires_later 262 + ?same_site:None 263 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 264 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 265 + () 266 + in 267 + 268 + (* Add a session cookie (no expiry) *) 269 + let cookie3 = 270 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"session" ~value:"value3" 271 + ~secure:false ~http_only:false ?expires:None ?same_site:None 272 + ~creation_time:(Ptime.of_float_s 1000.0 |> Option.get) 273 + ~last_access:(Ptime.of_float_s 1000.0 |> Option.get) 274 + () 275 + in 276 + 277 + add_cookie jar cookie1; 278 + add_cookie jar cookie2; 279 + add_cookie jar cookie3; 280 + 281 + Alcotest.(check int) "initial count" 3 (count jar); 282 + 283 + (* Advance time to 1600.0 - first cookie should expire *) 284 + Eio_mock.Clock.set_time clock 1600.0; 285 + clear_expired jar ~clock; 286 + 287 + Alcotest.(check int) "after first expiry" 2 (count jar); 288 + 289 + let cookies = get_all_cookies jar in 290 + let names = List.map Cookeio.name cookies |> List.sort String.compare in 291 + Alcotest.(check (list string)) 292 + "remaining cookies after 1600s" [ "expires_later"; "session" ] names; 293 + 294 + (* Advance time to 2100.0 - second cookie should expire *) 295 + Eio_mock.Clock.set_time clock 2100.0; 296 + clear_expired jar ~clock; 297 + 298 + Alcotest.(check int) "after second expiry" 1 (count jar); 299 + 300 + let remaining = get_all_cookies jar in 301 + Alcotest.(check string) "only session cookie remains" "session" 302 + (Cookeio.name (List.hd remaining)) 303 + 304 + let test_max_age_parsing_with_mock_clock () = 305 + Eio_mock.Backend.run @@ fun () -> 306 + let clock = Eio_mock.Clock.make () in 307 + 308 + (* Start at a known time *) 309 + Eio_mock.Clock.set_time clock 5000.0; 310 + 311 + (* Parse a Set-Cookie header with Max-Age *) 312 + let header = "session=abc123; Max-Age=3600; Secure; HttpOnly" in 313 + let cookie_opt = 314 + parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header 315 + in 316 + 317 + Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt); 318 + 319 + let cookie = Option.get cookie_opt in 320 + Alcotest.(check string) "cookie name" "session" (Cookeio.name cookie); 321 + Alcotest.(check string) "cookie value" "abc123" (Cookeio.value cookie); 322 + Alcotest.(check bool) "cookie secure" true (Cookeio.secure cookie); 323 + Alcotest.(check bool) "cookie http_only" true (Cookeio.http_only cookie); 324 + 325 + (* Verify the expiry time is set correctly (5000.0 + 3600 = 8600.0) *) 326 + let expected_expiry = Ptime.of_float_s 8600.0 in 327 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 328 + "expires set from max-age" expected_expiry (Cookeio.expires cookie); 329 + 330 + (* Verify creation time matches clock time *) 331 + let expected_creation = Ptime.of_float_s 5000.0 in 332 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 333 + "creation time" expected_creation 334 + (Some (Cookeio.creation_time cookie)) 335 + 336 + let test_last_access_time_with_mock_clock () = 337 + Eio_mock.Backend.run @@ fun () -> 338 + let clock = Eio_mock.Clock.make () in 339 + 340 + (* Start at time 3000.0 *) 341 + Eio_mock.Clock.set_time clock 3000.0; 342 + 343 + let jar = create () in 344 + 345 + (* Add a cookie *) 346 + let cookie = 347 + Cookeio.make ~domain:"example.com" ~path:"/" ~name:"test" ~value:"value" 348 + ~secure:false ~http_only:false ?expires:None ?same_site:None 349 + ~creation_time:(Ptime.of_float_s 3000.0 |> Option.get) 350 + ~last_access:(Ptime.of_float_s 3000.0 |> Option.get) 351 + () 352 + in 353 + add_cookie jar cookie; 354 + 355 + (* Verify initial last access time *) 356 + let cookies1 = get_all_cookies jar in 357 + let cookie1 = List.hd cookies1 in 358 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 359 + "initial last access" (Ptime.of_float_s 3000.0) 360 + (Some (Cookeio.last_access cookie1)); 361 + 362 + (* Advance time to 4000.0 *) 363 + Eio_mock.Clock.set_time clock 4000.0; 364 + 365 + (* Get cookies, which should update last access time to current clock time *) 366 + let _retrieved = 367 + get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false 368 + in 369 + 370 + (* Verify last access time was updated to the new clock time *) 371 + let cookies2 = get_all_cookies jar in 372 + let cookie2 = List.hd cookies2 in 373 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 374 + "updated last access" (Ptime.of_float_s 4000.0) 375 + (Some (Cookeio.last_access cookie2)) 376 + 377 + let test_parse_set_cookie_with_expires () = 378 + Eio_mock.Backend.run @@ fun () -> 379 + let clock = Eio_mock.Clock.make () in 380 + 381 + (* Start at a known time *) 382 + Eio_mock.Clock.set_time clock 6000.0; 383 + 384 + (* Use RFC3339 format which is what Ptime.of_rfc3339 expects *) 385 + let header = 386 + "id=xyz789; Expires=2025-10-21T07:28:00Z; Path=/; Domain=.example.com" 387 + in 388 + let cookie_opt = 389 + parse_set_cookie ~clock ~domain:"example.com" ~path:"/" header 390 + in 391 + 392 + Alcotest.(check bool) "cookie parsed" true (Option.is_some cookie_opt); 393 + 394 + let cookie = Option.get cookie_opt in 395 + Alcotest.(check string) "cookie name" "id" (Cookeio.name cookie); 396 + Alcotest.(check string) "cookie value" "xyz789" (Cookeio.value cookie); 397 + Alcotest.(check string) "cookie domain" ".example.com" (Cookeio.domain cookie); 398 + Alcotest.(check string) "cookie path" "/" (Cookeio.path cookie); 399 + 400 + (* Verify expires is parsed correctly *) 401 + Alcotest.(check bool) "has expiry" true 402 + (Option.is_some (Cookeio.expires cookie)); 403 + 404 + (* Verify the specific expiry time parsed from the RFC3339 date *) 405 + let expected_expiry = Ptime.of_rfc3339 "2025-10-21T07:28:00Z" in 406 + match expected_expiry with 407 + | Ok (time, _, _) -> 408 + Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal))) 409 + "expires matches parsed value" (Some time) (Cookeio.expires cookie) 410 + | Error _ -> Alcotest.fail "Failed to parse expected expiry time" 411 + 412 + let test_samesite_none_validation () = 413 + Eio_mock.Backend.run @@ fun () -> 414 + let clock = Eio_mock.Clock.make () in 415 + 416 + (* Start at a known time *) 417 + Eio_mock.Clock.set_time clock 7000.0; 418 + 419 + (* This should be rejected: SameSite=None without Secure *) 420 + let invalid_header = "token=abc; SameSite=None" in 421 + let cookie_opt = 422 + parse_set_cookie ~clock ~domain:"example.com" ~path:"/" invalid_header 423 + in 424 + 425 + Alcotest.(check bool) "invalid cookie rejected" true (Option.is_none cookie_opt); 426 + 427 + (* This should be accepted: SameSite=None with Secure *) 428 + let valid_header = "token=abc; SameSite=None; Secure" in 429 + let cookie_opt2 = 430 + parse_set_cookie ~clock ~domain:"example.com" ~path:"/" valid_header 431 + in 432 + 433 + Alcotest.(check bool) "valid cookie accepted" true (Option.is_some cookie_opt2); 434 + 435 + let cookie = Option.get cookie_opt2 in 436 + Alcotest.(check bool) "cookie is secure" true (Cookeio.secure cookie); 437 + Alcotest.( 438 + check 439 + (option 440 + (Alcotest.testable 441 + (fun ppf -> function 442 + | `Strict -> Format.pp_print_string ppf "Strict" 443 + | `Lax -> Format.pp_print_string ppf "Lax" 444 + | `None -> Format.pp_print_string ppf "None") 445 + ( = )))) 446 + "samesite is None" (Some `None) (Cookeio.same_site cookie) 447 + 237 448 let () = 238 449 Eio_main.run @@ fun env -> 239 450 let open Alcotest in ··· 256 467 ( "basic_operations", 257 468 [ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ] 258 469 ); 470 + ( "time_handling", 471 + [ 472 + test_case "Cookie expiry with mock clock" `Quick 473 + test_cookie_expiry_with_mock_clock; 474 + test_case "Max-Age parsing with mock clock" `Quick 475 + test_max_age_parsing_with_mock_clock; 476 + test_case "Last access time with mock clock" `Quick 477 + test_last_access_time_with_mock_clock; 478 + test_case "Parse Set-Cookie with Expires" `Quick 479 + test_parse_set_cookie_with_expires; 480 + test_case "SameSite=None validation" `Quick 481 + test_samesite_none_validation; 482 + ] ); 259 483 ]