OCaml HTTP cookie handling library with support for Eio-based storage jars
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))