OCaml HTTP cookie handling library with support for Eio-based storage jars
at main 22 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6let src = Logs.Src.create "cookie_jar" ~doc:"Cookie jar management" 7 8module Log = (val Logs.src_log src : Logs.LOG) 9 10type t = { 11 mutable original_cookies : Cookeio.t list; (* from client *) 12 mutable delta_cookies : Cookeio.t list; (* to send back *) 13 mutex : Eio.Mutex.t; 14} 15(** Cookie jar for storing and managing cookies *) 16 17(** {1 Cookie Jar Creation} *) 18 19let create () = 20 Log.debug (fun m -> m "Creating new empty cookie jar"); 21 { original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () } 22 23(** {1 Cookie Matching Helpers} *) 24 25(** Two cookies are considered identical if they have the same name, domain, 26 and path. This is used when replacing or removing cookies. 27 28 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 29let cookie_identity_matches c1 c2 = 30 Cookeio.name c1 = Cookeio.name c2 31 && Cookeio.domain c1 = Cookeio.domain c2 32 && Cookeio.path c1 = Cookeio.path c2 33 34(** Normalize a domain by stripping the leading dot. 35 36 Per RFC 6265, the Domain attribute value is canonicalized by removing any 37 leading dot before storage. 38 39 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3> RFC 6265 Section 5.2.3 - The Domain Attribute *) 40let normalize_domain domain = 41 match String.starts_with ~prefix:"." domain with 42 | true when String.length domain > 1 -> 43 String.sub domain 1 (String.length domain - 1) 44 | _ -> domain 45 46(** Remove duplicate cookies, keeping the last occurrence. 47 48 Used to deduplicate combined cookie lists where delta cookies should 49 take precedence over original cookies. *) 50let dedup_by_identity cookies = 51 let rec aux acc = function 52 | [] -> List.rev acc 53 | c :: rest -> 54 let has_duplicate = 55 List.exists (fun c2 -> cookie_identity_matches c c2) rest 56 in 57 if has_duplicate then aux acc rest else aux (c :: acc) rest 58 in 59 aux [] cookies 60 61(** Check if a string is an IP address (IPv4 or IPv6). 62 63 Per RFC 6265 Section 5.1.3, domain matching should only apply to hostnames, 64 not IP addresses. IP addresses require exact match only. 65 66 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> RFC 6265 Section 5.1.3 - Domain Matching *) 67let is_ip_address domain = Result.is_ok (Ipaddr.of_string domain) 68 69(** Check if a cookie domain matches a request domain. 70 71 Per RFC 6265 Section 5.1.3, a string domain-matches a given domain string if: 72 - The domain string and the string are identical, OR 73 - All of the following are true: 74 - The domain string is a suffix of the string 75 - The last character of the string not in the domain string is "." 76 - The string is a host name (i.e., not an IP address) 77 78 Additionally, per Section 5.3 Step 6, if the cookie has the host-only-flag 79 set, only exact matches are allowed. 80 81 @param host_only If true, only exact domain match is allowed 82 @param cookie_domain The domain stored in the cookie (without leading dot) 83 @param request_domain The domain from the HTTP request 84 @return true if the cookie should be sent for this domain 85 86 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3> RFC 6265 Section 5.1.3 - Domain Matching 87 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model (host-only-flag) *) 88let domain_matches ~host_only cookie_domain request_domain = 89 request_domain = cookie_domain 90 || (not (is_ip_address request_domain || host_only) 91 && String.ends_with ~suffix:("." ^ cookie_domain) request_domain) 92 93(** Check if a cookie path matches a request path. 94 95 Per RFC 6265 Section 5.1.4, a request-path path-matches a given cookie-path if: 96 - The cookie-path and the request-path are identical, OR 97 - The cookie-path is a prefix of the request-path, AND either: 98 - The last character of the cookie-path is "/", OR 99 - The first character of the request-path that is not included in the 100 cookie-path is "/" 101 102 @param cookie_path The path stored in the cookie 103 @param request_path The path from the HTTP request 104 @return true if the cookie should be sent for this path 105 106 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4> RFC 6265 Section 5.1.4 - Paths and Path-Match *) 107let path_matches cookie_path request_path = 108 if cookie_path = request_path then true 109 else if String.starts_with ~prefix:cookie_path request_path then 110 let cookie_len = String.length cookie_path in 111 String.ends_with ~suffix:"/" cookie_path 112 || (String.length request_path > cookie_len && request_path.[cookie_len] = '/') 113 else false 114 115(** {1 Cookie Expiration} *) 116 117(** Check if a cookie has expired based on its expiry-time. 118 119 Per RFC 6265 Section 5.3, a cookie is expired if the current date and time 120 is past the expiry-time. Session cookies (with no Expires or Max-Age) never 121 expire via this check - they expire when the "session" ends. 122 123 @param cookie The cookie to check 124 @param clock The Eio clock for current time 125 @return true if the cookie has expired 126 127 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 128let is_expired cookie clock = 129 match Cookeio.expires cookie with 130 | None -> false (* No expiration *) 131 | Some `Session -> 132 false (* Session cookie - not expired until browser closes *) 133 | Some (`DateTime exp_time) -> 134 let now = 135 Ptime.of_float_s (Eio.Time.now clock) 136 |> Option.value ~default:Ptime.epoch 137 in 138 Ptime.compare now exp_time > 0 139 140let pp ppf jar = 141 Eio.Mutex.lock jar.mutex; 142 let original = jar.original_cookies in 143 let delta = jar.delta_cookies in 144 Eio.Mutex.unlock jar.mutex; 145 146 let all_cookies = original @ delta in 147 Format.fprintf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@," 148 (List.length all_cookies) (List.length original) (List.length delta); 149 List.iter 150 (fun cookie -> Format.fprintf ppf " %a@," Cookeio.pp cookie) 151 all_cookies; 152 Format.fprintf ppf "@]" 153 154(** {1 Cookie Management} *) 155 156(** Preserve creation time from an existing cookie when replacing. 157 158 Per RFC 6265 Section 5.3, Step 11.3: "If the newly created cookie was 159 received from a 'non-HTTP' API and the old-cookie's http-only-flag is 160 true, abort these steps and ignore the newly created cookie entirely." 161 Step 11.3 also states: "Update the creation-time of the old-cookie to 162 match the creation-time of the newly created cookie." 163 164 However, the common interpretation (and browser behavior) is to preserve 165 the original creation-time when updating a cookie. This matches what 166 Step 3 of Section 5.4 uses for ordering (creation-time stability). 167 168 @param old_cookie The existing cookie being replaced (if any) 169 @param new_cookie The new cookie to add 170 @return The new cookie with creation_time preserved from old_cookie if present 171 172 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 173let preserve_creation_time old_cookie_opt new_cookie = 174 match old_cookie_opt with 175 | None -> new_cookie 176 | Some old_cookie -> 177 Cookeio.make ~domain:(Cookeio.domain new_cookie) 178 ~path:(Cookeio.path new_cookie) ~name:(Cookeio.name new_cookie) 179 ~value:(Cookeio.value new_cookie) ~secure:(Cookeio.secure new_cookie) 180 ~http_only:(Cookeio.http_only new_cookie) 181 ?expires:(Cookeio.expires new_cookie) 182 ?max_age:(Cookeio.max_age new_cookie) 183 ?same_site:(Cookeio.same_site new_cookie) 184 ~partitioned:(Cookeio.partitioned new_cookie) 185 ~host_only:(Cookeio.host_only new_cookie) 186 ~creation_time:(Cookeio.creation_time old_cookie) 187 ~last_access:(Cookeio.last_access new_cookie) 188 () 189 190let add_cookie jar cookie = 191 Log.debug (fun m -> 192 m "Adding cookie to delta: %s=%s for domain %s" (Cookeio.name cookie) 193 (Cookeio.value cookie) (Cookeio.domain cookie)); 194 195 Eio.Mutex.lock jar.mutex; 196 197 (* Find existing cookie with same identity to preserve creation_time 198 per RFC 6265 Section 5.3, Step 11.3 *) 199 let existing = 200 List.find_opt (fun c -> cookie_identity_matches c cookie) jar.delta_cookies 201 in 202 let existing = 203 match existing with 204 | Some _ -> existing 205 | None -> 206 (* Also check original cookies for creation time preservation *) 207 List.find_opt 208 (fun c -> cookie_identity_matches c cookie) 209 jar.original_cookies 210 in 211 212 let cookie = preserve_creation_time existing cookie in 213 214 (* Remove existing cookie with same identity from delta *) 215 jar.delta_cookies <- 216 List.filter 217 (fun c -> not (cookie_identity_matches c cookie)) 218 jar.delta_cookies; 219 jar.delta_cookies <- cookie :: jar.delta_cookies; 220 Eio.Mutex.unlock jar.mutex 221 222let add_original jar cookie = 223 Log.debug (fun m -> 224 m "Adding original cookie: %s=%s for domain %s" (Cookeio.name cookie) 225 (Cookeio.value cookie) (Cookeio.domain cookie)); 226 227 Eio.Mutex.lock jar.mutex; 228 229 (* Find existing cookie with same identity to preserve creation_time 230 per RFC 6265 Section 5.3, Step 11.3 *) 231 let existing = 232 List.find_opt 233 (fun c -> cookie_identity_matches c cookie) 234 jar.original_cookies 235 in 236 237 let cookie = preserve_creation_time existing cookie in 238 239 (* Remove existing cookie with same identity from original *) 240 jar.original_cookies <- 241 List.filter 242 (fun c -> not (cookie_identity_matches c cookie)) 243 jar.original_cookies; 244 jar.original_cookies <- cookie :: jar.original_cookies; 245 Eio.Mutex.unlock jar.mutex 246 247let delta jar = 248 Eio.Mutex.lock jar.mutex; 249 let result = jar.delta_cookies in 250 Eio.Mutex.unlock jar.mutex; 251 Log.debug (fun m -> m "Returning %d delta cookies" (List.length result)); 252 result 253 254(** Create a removal cookie for deleting a cookie from the client. 255 256 Per RFC 6265 Section 5.3, to remove a cookie, the server sends a Set-Cookie 257 header with an expiry date in the past. We also set Max-Age=0 and an empty 258 value for maximum compatibility. 259 260 @param cookie The cookie to create a removal for 261 @param clock The Eio clock for timestamps 262 @return A new cookie configured to cause deletion 263 264 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.3> RFC 6265 Section 5.3 - Storage Model *) 265let make_removal_cookie cookie ~clock = 266 let now = 267 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 268 in 269 (* Create a cookie with Max-Age=0 and past expiration (1 year ago) *) 270 let past_expiry = 271 Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60)) 272 |> Option.value ~default:Ptime.epoch 273 in 274 Cookeio.make ~domain:(Cookeio.domain cookie) ~path:(Cookeio.path cookie) 275 ~name:(Cookeio.name cookie) ~value:"" ~secure:(Cookeio.secure cookie) 276 ~http_only:(Cookeio.http_only cookie) ~expires:(`DateTime past_expiry) 277 ~max_age:(Ptime.Span.of_int_s 0) ?same_site:(Cookeio.same_site cookie) 278 ~partitioned:(Cookeio.partitioned cookie) 279 ~host_only:(Cookeio.host_only cookie) 280 ~creation_time:now ~last_access:now () 281 282let remove jar ~clock cookie = 283 Log.debug (fun m -> 284 m "Removing cookie: %s=%s for domain %s" (Cookeio.name cookie) 285 (Cookeio.value cookie) (Cookeio.domain cookie)); 286 287 Eio.Mutex.lock jar.mutex; 288 (* Check if this cookie exists in original_cookies *) 289 let in_original = 290 List.exists (fun c -> cookie_identity_matches c cookie) jar.original_cookies 291 in 292 293 if in_original then ( 294 (* Create a removal cookie and add it to delta *) 295 let removal = make_removal_cookie cookie ~clock in 296 jar.delta_cookies <- 297 List.filter 298 (fun c -> not (cookie_identity_matches c removal)) 299 jar.delta_cookies; 300 jar.delta_cookies <- removal :: jar.delta_cookies; 301 Log.debug (fun m -> m "Created removal cookie in delta for original cookie")) 302 else ( 303 (* Just remove from delta if it exists there *) 304 jar.delta_cookies <- 305 List.filter 306 (fun c -> not (cookie_identity_matches c cookie)) 307 jar.delta_cookies; 308 Log.debug (fun m -> m "Removed cookie from delta")); 309 310 Eio.Mutex.unlock jar.mutex 311 312(** Compare cookies for ordering per RFC 6265 Section 5.4, Step 2. 313 314 Cookies SHOULD be sorted: 315 1. Cookies with longer paths listed first 316 2. Among equal-length paths, cookies with earlier creation-times first 317 318 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *) 319let compare_cookie_order c1 c2 = 320 let path1_len = String.length (Cookeio.path c1) in 321 let path2_len = String.length (Cookeio.path c2) in 322 (* Longer paths first (descending order) *) 323 match Int.compare path2_len path1_len with 324 | 0 -> 325 (* Equal path lengths: earlier creation time first (ascending order) *) 326 Ptime.compare (Cookeio.creation_time c1) (Cookeio.creation_time c2) 327 | n -> n 328 329(** Retrieve cookies that should be sent for a given request. 330 331 Per RFC 6265 Section 5.4, the user agent should include a Cookie header 332 containing cookies that match the request-uri's domain, path, and security 333 context. This function also updates the last-access-time for matched cookies. 334 335 Cookies are sorted per Section 5.4, Step 2: 336 1. Cookies with longer paths listed first 337 2. Among equal-length paths, earlier creation-times listed first 338 339 @param jar The cookie jar to search 340 @param clock The Eio clock for timestamp updates 341 @param domain The request domain (hostname or IP address) 342 @param path The request path 343 @param is_secure Whether the request is over a secure channel (HTTPS) 344 @return List of cookies that should be included in the Cookie header, sorted 345 346 @see <https://datatracker.ietf.org/doc/html/rfc6265#section-5.4> RFC 6265 Section 5.4 - The Cookie Header *) 347let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure 348 = 349 Log.debug (fun m -> 350 m "Getting cookies for domain=%s path=%s secure=%b" request_domain 351 request_path is_secure); 352 353 Eio.Mutex.lock jar.mutex; 354 355 (* Combine original and delta cookies, with delta taking precedence *) 356 let all_cookies = jar.original_cookies @ jar.delta_cookies in 357 let unique_cookies = dedup_by_identity all_cookies in 358 359 (* Filter for applicable cookies, excluding removal cookies and expired cookies *) 360 let applicable = 361 List.filter 362 (fun cookie -> 363 Cookeio.value cookie <> "" 364 (* Exclude removal cookies *) 365 && (not (is_expired cookie clock)) 366 (* Exclude expired cookies *) 367 && domain_matches ~host_only:(Cookeio.host_only cookie) 368 (Cookeio.domain cookie) request_domain 369 && path_matches (Cookeio.path cookie) request_path 370 && ((not (Cookeio.secure cookie)) || is_secure)) 371 unique_cookies 372 in 373 374 (* Sort cookies per RFC 6265 Section 5.4, Step 2: 375 - Longer paths first 376 - Equal paths: earlier creation time first *) 377 let sorted = List.sort compare_cookie_order applicable in 378 379 (* Update last access time in both lists *) 380 let now = 381 Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch 382 in 383 let update_last_access cookies = 384 List.map 385 (fun c -> 386 if List.exists (fun a -> cookie_identity_matches a c) applicable then 387 Cookeio.make ~domain:(Cookeio.domain c) ~path:(Cookeio.path c) 388 ~name:(Cookeio.name c) ~value:(Cookeio.value c) 389 ~secure:(Cookeio.secure c) ~http_only:(Cookeio.http_only c) 390 ?expires:(Cookeio.expires c) ?max_age:(Cookeio.max_age c) 391 ?same_site:(Cookeio.same_site c) 392 ~partitioned:(Cookeio.partitioned c) 393 ~host_only:(Cookeio.host_only c) 394 ~creation_time:(Cookeio.creation_time c) ~last_access:now () 395 else c) 396 cookies 397 in 398 jar.original_cookies <- update_last_access jar.original_cookies; 399 jar.delta_cookies <- update_last_access jar.delta_cookies; 400 401 Eio.Mutex.unlock jar.mutex; 402 403 Log.debug (fun m -> m "Found %d applicable cookies" (List.length sorted)); 404 sorted 405 406let clear jar = 407 Log.info (fun m -> m "Clearing all cookies"); 408 Eio.Mutex.lock jar.mutex; 409 jar.original_cookies <- []; 410 jar.delta_cookies <- []; 411 Eio.Mutex.unlock jar.mutex 412 413let clear_expired jar ~clock = 414 Eio.Mutex.lock jar.mutex; 415 let before_count = 416 List.length jar.original_cookies + List.length jar.delta_cookies 417 in 418 jar.original_cookies <- 419 List.filter (fun c -> not (is_expired c clock)) jar.original_cookies; 420 jar.delta_cookies <- 421 List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies; 422 let removed = 423 before_count 424 - (List.length jar.original_cookies + List.length jar.delta_cookies) 425 in 426 Eio.Mutex.unlock jar.mutex; 427 Log.info (fun m -> m "Cleared %d expired cookies" removed) 428 429let clear_session_cookies jar = 430 Eio.Mutex.lock jar.mutex; 431 let before_count = 432 List.length jar.original_cookies + List.length jar.delta_cookies 433 in 434 (* Keep only cookies that are NOT session cookies *) 435 let is_not_session c = 436 match Cookeio.expires c with 437 | Some `Session -> false (* This is a session cookie, remove it *) 438 | None | Some (`DateTime _) -> true (* Keep these *) 439 in 440 jar.original_cookies <- List.filter is_not_session jar.original_cookies; 441 jar.delta_cookies <- List.filter is_not_session jar.delta_cookies; 442 let removed = 443 before_count 444 - (List.length jar.original_cookies + List.length jar.delta_cookies) 445 in 446 Eio.Mutex.unlock jar.mutex; 447 Log.info (fun m -> m "Cleared %d session cookies" removed) 448 449let count jar = 450 Eio.Mutex.lock jar.mutex; 451 let all_cookies = jar.original_cookies @ jar.delta_cookies in 452 let unique = dedup_by_identity all_cookies in 453 let n = List.length unique in 454 Eio.Mutex.unlock jar.mutex; 455 n 456 457let get_all_cookies jar = 458 Eio.Mutex.lock jar.mutex; 459 let all_cookies = jar.original_cookies @ jar.delta_cookies in 460 let unique = dedup_by_identity all_cookies in 461 Eio.Mutex.unlock jar.mutex; 462 unique 463 464let is_empty jar = 465 Eio.Mutex.lock jar.mutex; 466 let empty = jar.original_cookies = [] && jar.delta_cookies = [] in 467 Eio.Mutex.unlock jar.mutex; 468 empty 469 470(** {1 Mozilla Format} *) 471 472let to_mozilla_format_internal jar = 473 let buffer = Buffer.create 1024 in 474 Buffer.add_string buffer "# Netscape HTTP Cookie File\n"; 475 Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n"; 476 477 (* Combine and deduplicate cookies *) 478 let all_cookies = jar.original_cookies @ jar.delta_cookies in 479 let unique = dedup_by_identity all_cookies in 480 481 List.iter 482 (fun cookie -> 483 (* Mozilla format: include_subdomains=TRUE means host_only=false *) 484 let include_subdomains = if Cookeio.host_only cookie then "FALSE" else "TRUE" in 485 let secure_flag = if Cookeio.secure cookie then "TRUE" else "FALSE" in 486 let expires_str = 487 match Cookeio.expires cookie with 488 | None -> "0" (* No expiration *) 489 | Some `Session -> "0" (* Session cookie *) 490 | Some (`DateTime t) -> 491 let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in 492 epoch 493 in 494 495 Buffer.add_string buffer 496 (Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (Cookeio.domain cookie) 497 include_subdomains (Cookeio.path cookie) secure_flag expires_str 498 (Cookeio.name cookie) (Cookeio.value cookie))) 499 unique; 500 501 Buffer.contents buffer 502 503let to_mozilla_format jar = 504 Eio.Mutex.lock jar.mutex; 505 let result = to_mozilla_format_internal jar in 506 Eio.Mutex.unlock jar.mutex; 507 result 508 509let from_mozilla_format ~clock content = 510 Log.debug (fun m -> m "Parsing Mozilla format cookies"); 511 let jar = create () in 512 513 let lines = String.split_on_char '\n' content in 514 List.iter 515 (fun line -> 516 let line = String.trim line in 517 if line <> "" && not (String.starts_with ~prefix:"#" line) then 518 match String.split_on_char '\t' line with 519 | [ domain; include_subdomains; path; secure; expires; name; value ] -> 520 let now = 521 Ptime.of_float_s (Eio.Time.now clock) 522 |> Option.value ~default:Ptime.epoch 523 in 524 let expires = 525 match int_of_string_opt expires with 526 | Some exp_int when exp_int <> 0 -> 527 Option.map (fun t -> `DateTime t) 528 (Ptime.of_float_s (float_of_int exp_int)) 529 | _ -> None 530 in 531 (* Mozilla format: include_subdomains=TRUE means host_only=false *) 532 let host_only = include_subdomains <> "TRUE" in 533 534 let cookie = 535 Cookeio.make ~domain:(normalize_domain domain) ~path ~name ~value 536 ~secure:(secure = "TRUE") ~http_only:false ?expires 537 ?max_age:None ?same_site:None ~partitioned:false ~host_only 538 ~creation_time:now ~last_access:now () 539 in 540 add_original jar cookie; 541 Log.debug (fun m -> m "Loaded cookie: %s=%s" name value) 542 | _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line)) 543 lines; 544 545 Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies)); 546 jar 547 548(** {1 File Operations} *) 549 550let load ~clock path = 551 Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path); 552 553 try 554 let content = Eio.Path.load path in 555 from_mozilla_format ~clock content 556 with 557 | Eio.Io _ -> 558 Log.info (fun m -> m "Cookie file not found, creating empty jar"); 559 create () 560 | exn -> 561 Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn)); 562 create () 563 564let save path jar = 565 Eio.Mutex.lock jar.mutex; 566 let total_cookies = 567 List.length jar.original_cookies + List.length jar.delta_cookies 568 in 569 Eio.Mutex.unlock jar.mutex; 570 Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path); 571 572 let content = to_mozilla_format jar in 573 574 try 575 Eio.Path.save ~create:(`Or_truncate 0o600) path content; 576 Log.debug (fun m -> m "Cookies saved successfully") 577 with exn -> 578 Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))