···11-let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
22-33-module Log = (val Logs.src_log src : Logs.LOG)
44-55-module SameSite = struct
66- type t = [ `Strict | `Lax | `None ]
77-88- let equal = ( = )
99-1010- let pp ppf = function
1111- | `Strict -> Format.pp_print_string ppf "Strict"
1212- | `Lax -> Format.pp_print_string ppf "Lax"
1313- | `None -> Format.pp_print_string ppf "None"
1414-end
1515-1616-module Expiration = struct
1717- type t = [ `Session | `DateTime of Ptime.t ]
1818-1919- let equal e1 e2 =
2020- match (e1, e2) with
2121- | `Session, `Session -> true
2222- | `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
2323- | _ -> false
2424-2525- let pp ppf = function
2626- | `Session -> Format.pp_print_string ppf "Session"
2727- | `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
2828-end
2929-3030-type t = {
3131- domain : string;
3232- path : string;
3333- name : string;
3434- value : string;
3535- secure : bool;
3636- http_only : bool;
3737- partitioned : bool;
3838- expires : Expiration.t option;
3939- max_age : Ptime.Span.t option;
4040- same_site : SameSite.t option;
4141- creation_time : Ptime.t;
4242- last_access : Ptime.t;
4343-}
4444-(** HTTP Cookie *)
4545-4646-type jar = {
4747- mutable original_cookies : t list; (* from client *)
4848- mutable delta_cookies : t list; (* to send back *)
4949- mutex : Eio.Mutex.t;
5050-}
5151-(** Cookie jar for storing and managing cookies *)
5252-5353-(** {1 Cookie Accessors} *)
5454-5555-let domain cookie = cookie.domain
5656-let path cookie = cookie.path
5757-let name cookie = cookie.name
5858-let value cookie = cookie.value
5959-6060-let value_trimmed cookie =
6161- let v = cookie.value in
6262- let len = String.length v in
6363- if len < 2 then v
6464- else
6565- match (v.[0], v.[len - 1]) with
6666- | '"', '"' -> String.sub v 1 (len - 2)
6767- | _ -> v
6868-6969-let secure cookie = cookie.secure
7070-let http_only cookie = cookie.http_only
7171-let partitioned cookie = cookie.partitioned
7272-let expires cookie = cookie.expires
7373-let max_age cookie = cookie.max_age
7474-let same_site cookie = cookie.same_site
7575-let creation_time cookie = cookie.creation_time
7676-let last_access cookie = cookie.last_access
7777-7878-let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
7979- ?expires ?max_age ?same_site ?(partitioned = false) ~creation_time
8080- ~last_access () =
8181- {
8282- domain;
8383- path;
8484- name;
8585- value;
8686- secure;
8787- http_only;
8888- partitioned;
8989- expires;
9090- max_age;
9191- same_site;
9292- creation_time;
9393- last_access;
9494- }
9595-9696-(** {1 Cookie Jar Creation} *)
9797-9898-let create () =
9999- Log.debug (fun m -> m "Creating new empty cookie jar");
100100- { original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () }
101101-102102-(** {1 Cookie Matching Helpers} *)
103103-104104-let cookie_identity_matches c1 c2 =
105105- name c1 = name c2 && domain c1 = domain c2 && path c1 = path c2
106106-107107-let normalize_domain domain =
108108- (* Strip leading dot per RFC 6265 *)
109109- match String.starts_with ~prefix:"." domain with
110110- | true when String.length domain > 1 ->
111111- String.sub domain 1 (String.length domain - 1)
112112- | _ -> domain
113113-114114-let domain_matches cookie_domain request_domain =
115115- (* Cookie domains are stored without leading dots per RFC 6265.
116116- A cookie with domain "example.com" should match both "example.com" (exact)
117117- and "sub.example.com" (subdomain). *)
118118- request_domain = cookie_domain
119119- || String.ends_with ~suffix:("." ^ cookie_domain) request_domain
120120-121121-let path_matches cookie_path request_path =
122122- (* Cookie path /foo matches /foo, /foo/, /foo/bar *)
123123- String.starts_with ~prefix:cookie_path request_path
124124-125125-(** {1 HTTP Date Parsing} *)
126126-let is_expired cookie clock =
127127- match cookie.expires with
128128- | None -> false (* No expiration *)
129129- | Some `Session -> false (* Session cookie - not expired until browser closes *)
130130- | Some (`DateTime exp_time) ->
131131- let now =
132132- Ptime.of_float_s (Eio.Time.now clock)
133133- |> Option.value ~default:Ptime.epoch
134134- in
135135- Ptime.compare now exp_time > 0
136136-137137-module DateParser = struct
138138- (** Month name to number mapping (case-insensitive) *)
139139- let month_of_string s =
140140- match String.lowercase_ascii s with
141141- | "jan" -> Some 1
142142- | "feb" -> Some 2
143143- | "mar" -> Some 3
144144- | "apr" -> Some 4
145145- | "may" -> Some 5
146146- | "jun" -> Some 6
147147- | "jul" -> Some 7
148148- | "aug" -> Some 8
149149- | "sep" -> Some 9
150150- | "oct" -> Some 10
151151- | "nov" -> Some 11
152152- | "dec" -> Some 12
153153- | _ -> None
154154-155155- (** Normalize abbreviated years:
156156- - Years 69-99 get 1900 added (e.g., 95 → 1995)
157157- - Years 0-68 get 2000 added (e.g., 25 → 2025)
158158- - Years >= 100 are returned as-is *)
159159- let normalize_year year =
160160- if year >= 0 && year <= 68 then year + 2000
161161- else if year >= 69 && year <= 99 then year + 1900
162162- else year
163163-164164- (** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
165165- let parse_fmt1 s =
166166- try
167167- Scanf.sscanf s "%s %d %s %d %d:%d:%d %s"
168168- (fun _wday day mon year hour min sec tz ->
169169- (* Check timezone is GMT (case-insensitive) *)
170170- if String.lowercase_ascii tz <> "gmt" then None
171171- else
172172- match month_of_string mon with
173173- | None -> None
174174- | Some month ->
175175- let year = normalize_year year in
176176- Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
177177- with _ -> None
178178-179179- (** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *)
180180- let parse_fmt2 s =
181181- try
182182- Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s"
183183- (fun _wday day mon year hour min sec tz ->
184184- (* Check timezone is GMT (case-insensitive) *)
185185- if String.lowercase_ascii tz <> "gmt" then None
186186- else
187187- match month_of_string mon with
188188- | None -> None
189189- | Some month ->
190190- let year = normalize_year year in
191191- Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
192192- with _ -> None
193193-194194- (** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
195195- let parse_fmt3 s =
196196- try
197197- Scanf.sscanf s "%s %s %d %d:%d:%d %d"
198198- (fun _wday mon day hour min sec year ->
199199- match month_of_string mon with
200200- | None -> None
201201- | Some month ->
202202- let year = normalize_year year in
203203- Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
204204- with _ -> None
205205-206206- (** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
207207- let parse_fmt4 s =
208208- try
209209- Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s"
210210- (fun _wday day mon year hour min sec tz ->
211211- (* Check timezone is GMT (case-insensitive) *)
212212- if String.lowercase_ascii tz <> "gmt" then None
213213- else
214214- match month_of_string mon with
215215- | None -> None
216216- | Some month ->
217217- let year = normalize_year year in
218218- Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
219219- with _ -> None
220220-221221- (** Parse HTTP date by trying all supported formats in sequence *)
222222- let parse_http_date s =
223223- match parse_fmt1 s with
224224- | Some t -> Some t
225225- | None -> (
226226- match parse_fmt2 s with
227227- | Some t -> Some t
228228- | None -> (
229229- match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
230230-end
231231-232232-(** {1 Cookie Parsing} *)
233233-234234-type cookie_attributes = {
235235- mutable domain : string option;
236236- mutable path : string option;
237237- mutable secure : bool;
238238- mutable http_only : bool;
239239- mutable partitioned : bool;
240240- mutable expires : Expiration.t option;
241241- mutable max_age : Ptime.Span.t option;
242242- mutable same_site : SameSite.t option;
243243-}
244244-(** Accumulated attributes from parsing Set-Cookie header *)
245245-246246-(** Create empty attribute accumulator *)
247247-let empty_attributes () =
248248- {
249249- domain = None;
250250- path = None;
251251- secure = false;
252252- http_only = false;
253253- partitioned = false;
254254- expires = None;
255255- max_age = None;
256256- same_site = None;
257257- }
258258-259259-(** Parse a single attribute and update the accumulator in-place *)
260260-let parse_attribute clock attrs attr_name attr_value =
261261- let attr_lower = String.lowercase_ascii attr_name in
262262- match attr_lower with
263263- | "domain" -> attrs.domain <- Some (normalize_domain attr_value)
264264- | "path" -> attrs.path <- Some attr_value
265265- | "expires" -> (
266266- (* Special case: Expires=0 means session cookie *)
267267- if attr_value = "0" then attrs.expires <- Some `Session
268268- else
269269- match Ptime.of_rfc3339 attr_value with
270270- | Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
271271- | Error (`RFC3339 (_, err)) -> (
272272- (* Try HTTP date format as fallback *)
273273- match DateParser.parse_http_date attr_value with
274274- | Some time -> attrs.expires <- Some (`DateTime time)
275275- | None ->
276276- Log.warn (fun m ->
277277- m "Failed to parse expires attribute '%s': %a" attr_value
278278- Ptime.pp_rfc3339_error err)))
279279- | "max-age" -> (
280280- match int_of_string_opt attr_value with
281281- | Some seconds ->
282282- (* Handle negative values as 0 per RFC 6265 *)
283283- let seconds = max 0 seconds in
284284- let now = Eio.Time.now clock in
285285- (* Store the max-age as a Ptime.Span *)
286286- attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
287287- (* Also compute and store expires as DateTime *)
288288- let expires = Ptime.of_float_s (now +. float_of_int seconds) in
289289- (match expires with
290290- | Some time -> attrs.expires <- Some (`DateTime time)
291291- | None -> ());
292292- Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
293293- | None ->
294294- Log.warn (fun m ->
295295- m "Failed to parse max-age attribute '%s'" attr_value))
296296- | "secure" -> attrs.secure <- true
297297- | "httponly" -> attrs.http_only <- true
298298- | "partitioned" -> attrs.partitioned <- true
299299- | "samesite" -> (
300300- match String.lowercase_ascii attr_value with
301301- | "strict" -> attrs.same_site <- Some `Strict
302302- | "lax" -> attrs.same_site <- Some `Lax
303303- | "none" -> attrs.same_site <- Some `None
304304- | _ ->
305305- Log.warn (fun m ->
306306- m "Invalid samesite value '%s', ignoring" attr_value))
307307- | _ ->
308308- Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
309309-310310-(** Validate cookie attributes and log warnings for invalid combinations *)
311311-let validate_attributes attrs =
312312- (* SameSite=None requires Secure flag *)
313313- let samesite_valid =
314314- match attrs.same_site with
315315- | Some `None when not attrs.secure ->
316316- Log.warn (fun m ->
317317- m
318318- "Cookie has SameSite=None but Secure flag is not set; this \
319319- violates RFC requirements");
320320- false
321321- | _ -> true
322322- in
323323- (* Partitioned requires Secure flag *)
324324- let partitioned_valid =
325325- if attrs.partitioned && not attrs.secure then (
326326- Log.warn (fun m ->
327327- m
328328- "Cookie has Partitioned attribute but Secure flag is not set; \
329329- this violates CHIPS requirements");
330330- false)
331331- else true
332332- in
333333- samesite_valid && partitioned_valid
334334-335335-(** Build final cookie from name/value and accumulated attributes *)
336336-let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
337337- let domain =
338338- normalize_domain (Option.value attrs.domain ~default:request_domain)
339339- in
340340- let path = Option.value attrs.path ~default:request_path in
341341- make ~domain ~path ~name ~value ~secure:attrs.secure
342342- ~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
343343- ?same_site:attrs.same_site ~partitioned:attrs.partitioned
344344- ~creation_time:now ~last_access:now ()
345345-346346-let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
347347- header_value =
348348- Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
349349-350350- (* Split into attributes *)
351351- let parts = String.split_on_char ';' header_value |> List.map String.trim in
352352-353353- match parts with
354354- | [] -> None
355355- | name_value :: attrs -> (
356356- (* Parse name=value *)
357357- match String.index_opt name_value '=' with
358358- | None -> None
359359- | Some eq_pos ->
360360- let name = String.sub name_value 0 eq_pos |> String.trim in
361361- let cookie_value =
362362- String.sub name_value (eq_pos + 1)
363363- (String.length name_value - eq_pos - 1)
364364- |> String.trim
365365- in
366366-367367- let now =
368368- Ptime.of_float_s (Eio.Time.now clock)
369369- |> Option.value ~default:Ptime.epoch
370370- in
371371-372372- (* Parse all attributes into mutable accumulator *)
373373- let accumulated_attrs = empty_attributes () in
374374- List.iter
375375- (fun attr ->
376376- match String.index_opt attr '=' with
377377- | None ->
378378- (* Attribute without value (e.g., Secure, HttpOnly) *)
379379- parse_attribute clock accumulated_attrs attr ""
380380- | Some eq ->
381381- let attr_name = String.sub attr 0 eq |> String.trim in
382382- let attr_value =
383383- String.sub attr (eq + 1) (String.length attr - eq - 1)
384384- |> String.trim
385385- in
386386- parse_attribute clock accumulated_attrs attr_name attr_value)
387387- attrs;
388388-389389- (* Validate attributes *)
390390- if not (validate_attributes accumulated_attrs) then (
391391- Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
392392- None)
393393- else
394394- let cookie =
395395- build_cookie ~request_domain ~request_path ~name
396396- ~value:cookie_value accumulated_attrs ~now
397397- in
398398- Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
399399- Some cookie)
400400-401401-and of_cookie_header ~clock ~domain ~path header_value =
402402- Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
403403-404404- (* Split on semicolons *)
405405- let parts = String.split_on_char ';' header_value |> List.map String.trim in
406406-407407- (* Filter out empty parts *)
408408- let parts = List.filter (fun s -> String.length s > 0) parts in
409409-410410- (* Parse each name=value pair *)
411411- List.map
412412- (fun name_value ->
413413- match String.index_opt name_value '=' with
414414- | None ->
415415- Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
416416- | Some eq_pos ->
417417- let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
418418- if String.length cookie_name = 0 then
419419- Error "Cookie has empty name"
420420- else
421421- let cookie_value =
422422- String.sub name_value (eq_pos + 1)
423423- (String.length name_value - eq_pos - 1)
424424- |> String.trim
425425- in
426426- let now =
427427- Ptime.of_float_s (Eio.Time.now clock)
428428- |> Option.value ~default:Ptime.epoch
429429- in
430430- (* Create cookie with defaults from Cookie header context *)
431431- let cookie =
432432- make ~domain ~path ~name:cookie_name ~value:cookie_value
433433- ~secure:false ~http_only:false ~partitioned:false ~creation_time:now
434434- ~last_access:now ()
435435- in
436436- Ok cookie)
437437- parts
438438-439439-and make_cookie_header cookies =
440440- cookies
441441- |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
442442- |> String.concat "; "
443443-444444-and make_set_cookie_header cookie =
445445- let buffer = Buffer.create 128 in
446446- Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
447447-448448- (* Add Max-Age if present *)
449449- (match max_age cookie with
450450- | Some span -> (
451451- match Ptime.Span.to_int_s span with
452452- | Some seconds ->
453453- Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
454454- | None -> ())
455455- | None -> ());
456456-457457- (* Add Expires if present *)
458458- (match expires cookie with
459459- | Some `Session ->
460460- (* Session cookies can be indicated with Expires=0 or a past date *)
461461- Buffer.add_string buffer "; Expires=0"
462462- | Some (`DateTime exp_time) ->
463463- (* Format as HTTP date *)
464464- let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
465465- Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
466466- | None -> ());
467467-468468- (* Add Domain *)
469469- Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
470470-471471- (* Add Path *)
472472- Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie));
473473-474474- (* Add Secure flag *)
475475- if secure cookie then Buffer.add_string buffer "; Secure";
476476-477477- (* Add HttpOnly flag *)
478478- if http_only cookie then Buffer.add_string buffer "; HttpOnly";
479479-480480- (* Add Partitioned flag *)
481481- if partitioned cookie then Buffer.add_string buffer "; Partitioned";
482482-483483- (* Add SameSite *)
484484- (match same_site cookie with
485485- | Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
486486- | Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
487487- | Some `None -> Buffer.add_string buffer "; SameSite=None"
488488- | None -> ());
489489-490490- Buffer.contents buffer
491491-492492-(** {1 Pretty Printing} *)
493493-494494-and pp ppf cookie =
495495- Format.fprintf ppf
496496- "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
497497- http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
498498- (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
499499- (http_only cookie) (partitioned cookie)
500500- (Format.pp_print_option Expiration.pp)
501501- (expires cookie)
502502- (Format.pp_print_option Ptime.Span.pp)
503503- (max_age cookie)
504504- (Format.pp_print_option SameSite.pp)
505505- (same_site cookie)
506506-507507-let pp_jar ppf jar =
508508- Eio.Mutex.lock jar.mutex;
509509- let original = jar.original_cookies in
510510- let delta = jar.delta_cookies in
511511- Eio.Mutex.unlock jar.mutex;
512512-513513- let all_cookies = original @ delta in
514514- Format.fprintf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@,"
515515- (List.length all_cookies) (List.length original) (List.length delta);
516516- List.iter (fun cookie -> Format.fprintf ppf " %a@," pp cookie) all_cookies;
517517- Format.fprintf ppf "@]"
518518-519519-(** {1 Cookie Management} *)
520520-521521-let add_cookie jar cookie =
522522- Log.debug (fun m ->
523523- m "Adding cookie to delta: %s=%s for domain %s" (name cookie)
524524- (value cookie) (domain cookie));
525525-526526- Eio.Mutex.lock jar.mutex;
527527- (* Remove existing cookie with same identity from delta *)
528528- jar.delta_cookies <-
529529- List.filter
530530- (fun c -> not (cookie_identity_matches c cookie))
531531- jar.delta_cookies;
532532- jar.delta_cookies <- cookie :: jar.delta_cookies;
533533- Eio.Mutex.unlock jar.mutex
534534-535535-let add_original jar cookie =
536536- Log.debug (fun m ->
537537- m "Adding original cookie: %s=%s for domain %s" (name cookie)
538538- (value cookie) (domain cookie));
539539-540540- Eio.Mutex.lock jar.mutex;
541541- (* Remove existing cookie with same identity from original *)
542542- jar.original_cookies <-
543543- List.filter
544544- (fun c -> not (cookie_identity_matches c cookie))
545545- jar.original_cookies;
546546- jar.original_cookies <- cookie :: jar.original_cookies;
547547- Eio.Mutex.unlock jar.mutex
548548-549549-let delta jar =
550550- Eio.Mutex.lock jar.mutex;
551551- let result = jar.delta_cookies in
552552- Eio.Mutex.unlock jar.mutex;
553553- Log.debug (fun m -> m "Returning %d delta cookies" (List.length result));
554554- result
555555-556556-let make_removal_cookie cookie ~clock =
557557- let now =
558558- Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
559559- in
560560- (* Create a cookie with Max-Age=0 and past expiration (1 year ago) *)
561561- let past_expiry =
562562- Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60))
563563- |> Option.value ~default:Ptime.epoch
564564- in
565565- make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) ~value:""
566566- ~secure:(secure cookie) ~http_only:(http_only cookie)
567567- ~expires:(`DateTime past_expiry) ~max_age:(Ptime.Span.of_int_s 0)
568568- ?same_site:(same_site cookie) ~partitioned:(partitioned cookie)
569569- ~creation_time:now ~last_access:now ()
570570-571571-let remove jar ~clock cookie =
572572- Log.debug (fun m ->
573573- m "Removing cookie: %s=%s for domain %s" (name cookie) (value cookie)
574574- (domain cookie));
575575-576576- Eio.Mutex.lock jar.mutex;
577577- (* Check if this cookie exists in original_cookies *)
578578- let in_original =
579579- List.exists (fun c -> cookie_identity_matches c cookie) jar.original_cookies
580580- in
581581-582582- if in_original then (
583583- (* Create a removal cookie and add it to delta *)
584584- let removal = make_removal_cookie cookie ~clock in
585585- jar.delta_cookies <-
586586- List.filter
587587- (fun c -> not (cookie_identity_matches c removal))
588588- jar.delta_cookies;
589589- jar.delta_cookies <- removal :: jar.delta_cookies;
590590- Log.debug (fun m -> m "Created removal cookie in delta for original cookie"))
591591- else (
592592- (* Just remove from delta if it exists there *)
593593- jar.delta_cookies <-
594594- List.filter
595595- (fun c -> not (cookie_identity_matches c cookie))
596596- jar.delta_cookies;
597597- Log.debug (fun m -> m "Removed cookie from delta"));
598598-599599- Eio.Mutex.unlock jar.mutex
600600-601601-let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
602602- =
603603- Log.debug (fun m ->
604604- m "Getting cookies for domain=%s path=%s secure=%b" request_domain
605605- request_path is_secure);
606606-607607- Eio.Mutex.lock jar.mutex;
608608-609609- (* Combine original and delta cookies, with delta taking precedence *)
610610- let all_cookies = jar.original_cookies @ jar.delta_cookies in
611611-612612- (* Filter out duplicates, keeping the last occurrence (from delta) *)
613613- let rec dedup acc = function
614614- | [] -> List.rev acc
615615- | c :: rest ->
616616- (* Keep this cookie only if no later cookie has the same identity *)
617617- let has_duplicate =
618618- List.exists (fun c2 -> cookie_identity_matches c c2) rest
619619- in
620620- if has_duplicate then dedup acc rest else dedup (c :: acc) rest
621621- in
622622- let unique_cookies = dedup [] all_cookies in
623623-624624- (* Filter for applicable cookies, excluding removal cookies (empty value) *)
625625- let applicable =
626626- List.filter
627627- (fun cookie ->
628628- value cookie <> ""
629629- (* Exclude removal cookies *)
630630- && domain_matches (domain cookie) request_domain
631631- && path_matches (path cookie) request_path
632632- && ((not (secure cookie)) || is_secure))
633633- unique_cookies
634634- in
635635-636636- (* Update last access time in both lists *)
637637- let now =
638638- Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
639639- in
640640- let update_last_access cookies =
641641- List.map
642642- (fun c ->
643643- if List.exists (fun a -> cookie_identity_matches a c) applicable then
644644- make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c)
645645- ~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c)
646646- ?max_age:(max_age c) ?same_site:(same_site c)
647647- ~creation_time:(creation_time c) ~last_access:now ()
648648- else c)
649649- cookies
650650- in
651651- jar.original_cookies <- update_last_access jar.original_cookies;
652652- jar.delta_cookies <- update_last_access jar.delta_cookies;
653653-654654- Eio.Mutex.unlock jar.mutex;
655655-656656- Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
657657- applicable
658658-659659-let clear jar =
660660- Log.info (fun m -> m "Clearing all cookies");
661661- Eio.Mutex.lock jar.mutex;
662662- jar.original_cookies <- [];
663663- jar.delta_cookies <- [];
664664- Eio.Mutex.unlock jar.mutex
665665-666666-let clear_expired jar ~clock =
667667- Eio.Mutex.lock jar.mutex;
668668- let before_count =
669669- List.length jar.original_cookies + List.length jar.delta_cookies
670670- in
671671- jar.original_cookies <-
672672- List.filter (fun c -> not (is_expired c clock)) jar.original_cookies;
673673- jar.delta_cookies <-
674674- List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies;
675675- let removed =
676676- before_count
677677- - (List.length jar.original_cookies + List.length jar.delta_cookies)
678678- in
679679- Eio.Mutex.unlock jar.mutex;
680680- Log.info (fun m -> m "Cleared %d expired cookies" removed)
681681-682682-let clear_session_cookies jar =
683683- Eio.Mutex.lock jar.mutex;
684684- let before_count =
685685- List.length jar.original_cookies + List.length jar.delta_cookies
686686- in
687687- (* Keep only cookies that are NOT session cookies *)
688688- let is_not_session c =
689689- match expires c with
690690- | Some `Session -> false (* This is a session cookie, remove it *)
691691- | None | Some (`DateTime _) -> true (* Keep these *)
692692- in
693693- jar.original_cookies <- List.filter is_not_session jar.original_cookies;
694694- jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
695695- let removed =
696696- before_count
697697- - (List.length jar.original_cookies + List.length jar.delta_cookies)
698698- in
699699- Eio.Mutex.unlock jar.mutex;
700700- Log.info (fun m -> m "Cleared %d session cookies" removed)
701701-702702-let count jar =
703703- Eio.Mutex.lock jar.mutex;
704704- (* Combine and deduplicate cookies for count *)
705705- let all_cookies = jar.original_cookies @ jar.delta_cookies in
706706- let rec dedup acc = function
707707- | [] -> List.rev acc
708708- | c :: rest ->
709709- let has_duplicate =
710710- List.exists (fun c2 -> cookie_identity_matches c c2) rest
711711- in
712712- if has_duplicate then dedup acc rest else dedup (c :: acc) rest
713713- in
714714- let unique = dedup [] all_cookies in
715715- let n = List.length unique in
716716- Eio.Mutex.unlock jar.mutex;
717717- n
718718-719719-let get_all_cookies jar =
720720- Eio.Mutex.lock jar.mutex;
721721- (* Combine and deduplicate, with delta taking precedence *)
722722- let all_cookies = jar.original_cookies @ jar.delta_cookies in
723723- let rec dedup acc = function
724724- | [] -> List.rev acc
725725- | c :: rest ->
726726- let has_duplicate =
727727- List.exists (fun c2 -> cookie_identity_matches c c2) rest
728728- in
729729- if has_duplicate then dedup acc rest else dedup (c :: acc) rest
730730- in
731731- let unique = dedup [] all_cookies in
732732- Eio.Mutex.unlock jar.mutex;
733733- unique
734734-735735-let is_empty jar =
736736- Eio.Mutex.lock jar.mutex;
737737- let empty = jar.original_cookies = [] && jar.delta_cookies = [] in
738738- Eio.Mutex.unlock jar.mutex;
739739- empty
740740-741741-(** {1 Mozilla Format} *)
742742-743743-let to_mozilla_format_internal jar =
744744- let buffer = Buffer.create 1024 in
745745- Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
746746- Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
747747-748748- (* Combine and deduplicate cookies *)
749749- let all_cookies = jar.original_cookies @ jar.delta_cookies in
750750- let rec dedup acc = function
751751- | [] -> List.rev acc
752752- | c :: rest ->
753753- let has_duplicate =
754754- List.exists (fun c2 -> cookie_identity_matches c c2) rest
755755- in
756756- if has_duplicate then dedup acc rest else dedup (c :: acc) rest
757757- in
758758- let unique = dedup [] all_cookies in
759759-760760- List.iter
761761- (fun cookie ->
762762- let include_subdomains =
763763- if String.starts_with ~prefix:"." (domain cookie) then "TRUE"
764764- else "FALSE"
765765- in
766766- let secure_flag = if secure cookie then "TRUE" else "FALSE" in
767767- let expires_str =
768768- match expires cookie with
769769- | None -> "0" (* No expiration *)
770770- | Some `Session -> "0" (* Session cookie *)
771771- | Some (`DateTime t) ->
772772- let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
773773- epoch
774774- in
775775-776776- Buffer.add_string buffer
777777- (Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (domain cookie)
778778- include_subdomains (path cookie) secure_flag expires_str
779779- (name cookie) (value cookie)))
780780- unique;
781781-782782- Buffer.contents buffer
783783-784784-let to_mozilla_format jar =
785785- Eio.Mutex.lock jar.mutex;
786786- let result = to_mozilla_format_internal jar in
787787- Eio.Mutex.unlock jar.mutex;
788788- result
789789-790790-let from_mozilla_format ~clock content =
791791- Log.debug (fun m -> m "Parsing Mozilla format cookies");
792792- let jar = create () in
793793-794794- let lines = String.split_on_char '\n' content in
795795- List.iter
796796- (fun line ->
797797- let line = String.trim line in
798798- if line <> "" && not (String.starts_with ~prefix:"#" line) then
799799- match String.split_on_char '\t' line with
800800- | [ domain; _include_subdomains; path; secure; expires; name; value ] ->
801801- let now =
802802- Ptime.of_float_s (Eio.Time.now clock)
803803- |> Option.value ~default:Ptime.epoch
804804- in
805805- let expires =
806806- let exp_int = try int_of_string expires with _ -> 0 in
807807- if exp_int = 0 then None
808808- else
809809- match Ptime.of_float_s (float_of_int exp_int) with
810810- | Some t -> Some (`DateTime t)
811811- | None -> None
812812- in
813813-814814- let cookie =
815815- make ~domain:(normalize_domain domain) ~path ~name ~value
816816- ~secure:(secure = "TRUE") ~http_only:false ?expires ?max_age:None
817817- ?same_site:None ~partitioned:false ~creation_time:now
818818- ~last_access:now ()
819819- in
820820- add_original jar cookie;
821821- Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
822822- | _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line))
823823- lines;
824824-825825- Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies));
826826- jar
827827-828828-(** {1 File Operations} *)
829829-830830-let load ~clock path =
831831- Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
832832-833833- try
834834- let content = Eio.Path.load path in
835835- from_mozilla_format ~clock content
836836- with
837837- | Eio.Io _ ->
838838- Log.info (fun m -> m "Cookie file not found, creating empty jar");
839839- create ()
840840- | exn ->
841841- Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
842842- create ()
843843-844844-let save path jar =
845845- Eio.Mutex.lock jar.mutex;
846846- let total_cookies =
847847- List.length jar.original_cookies + List.length jar.delta_cookies
848848- in
849849- Eio.Mutex.unlock jar.mutex;
850850- Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path);
851851-852852- let content = to_mozilla_format jar in
853853-854854- try
855855- Eio.Path.save ~create:(`Or_truncate 0o600) path content;
856856- Log.debug (fun m -> m "Cookies saved successfully")
857857- with exn ->
858858- Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+9-108
lib/cookeio.mli
lib/core/cookeio.mli
···6767 its scope, security, and lifetime. Cookies with the same [name], [domain],
6868 and [path] will overwrite each other when added to a cookie jar. *)
69697070-type jar
7171-(** Cookie jar for storing and managing cookies.
7272-7373- A cookie jar maintains a collection of cookies with automatic cleanup of
7474- expired entries and enforcement of storage limits. It implements the
7575- standard browser behavior for cookie storage, including:
7676- - Automatic removal of expired cookies
7777- - LRU eviction when storage limits are exceeded
7878- - Domain and path-based cookie retrieval
7979- - Mozilla format persistence for cross-tool compatibility *)
8080-8170(** {1 Cookie Accessors} *)
82718372val domain : t -> string
···166155 Note: If [partitioned] is [true], the cookie must also be [secure]. Invalid
167156 combinations will result in validation errors. *)
168157169169-(** {1 Cookie Jar Creation and Loading} *)
170170-171171-val create : unit -> jar
172172-(** Create an empty cookie jar *)
173173-174174-val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> jar
175175-(** Load cookies from Mozilla format file.
176176-177177- Loads cookies from a file in Mozilla format, using the provided clock to set
178178- creation and last access times. Returns an empty jar if the file doesn't
179179- exist or cannot be loaded. *)
180180-181181-val save : Eio.Fs.dir_ty Eio.Path.t -> jar -> unit
182182-(** Save cookies to Mozilla format file *)
183183-184184-(** {1 Cookie Jar Management} *)
185185-186186-val add_cookie : jar -> t -> unit
187187-(** Add a cookie to the jar.
188188-189189- The cookie is added to the delta, meaning it will appear in Set-Cookie
190190- headers when calling {!delta}. If a cookie with the same name/domain/path
191191- exists in the delta, it will be replaced. *)
192192-193193-val add_original : jar -> t -> unit
194194-(** Add an original cookie to the jar.
195195-196196- Original cookies are those received from the client (via Cookie header).
197197- They do not appear in the delta. This method should be used when loading
198198- cookies from incoming HTTP requests. *)
199199-200200-val delta : jar -> t list
201201-(** Get cookies that need to be sent in Set-Cookie headers.
202202-203203- Returns cookies that have been added via {!add_cookie} and removal cookies
204204- for original cookies that have been removed. Does not include original
205205- cookies that were added via {!add_original}. *)
206206-207207-val remove : jar -> clock:_ Eio.Time.clock -> t -> unit
208208-(** Remove a cookie from the jar.
209209-210210- If an original cookie with the same name/domain/path exists, creates a
211211- removal cookie (empty value, Max-Age=0, past expiration) that appears in the
212212- delta. If only a delta cookie exists, simply removes it from the delta. *)
213213-214214-val get_cookies :
215215- jar ->
216216- clock:_ Eio.Time.clock ->
217217- domain:string ->
218218- path:string ->
219219- is_secure:bool ->
220220- t list
221221-(** Get cookies applicable for a URL.
222222-223223- Returns all cookies that match the given domain and path, and satisfy the
224224- secure flag requirement. Combines original and delta cookies, with delta
225225- taking precedence. Excludes removal cookies (empty value). Also updates the
226226- last access time of matching cookies using the provided clock. *)
227227-228228-val clear : jar -> unit
229229-(** Clear all cookies *)
230230-231231-val clear_expired : jar -> clock:_ Eio.Time.clock -> unit
232232-(** Clear expired cookies *)
233233-234234-val clear_session_cookies : jar -> unit
235235-(** Clear session cookies (those without expiry) *)
236236-237237-val count : jar -> int
238238-(** Get the number of cookies in the jar *)
239239-240240-val get_all_cookies : jar -> t list
241241-(** Get all cookies in the jar *)
242242-243243-val is_empty : jar -> bool
244244-(** Check if the jar is empty *)
245245-246158(** {1 Cookie Creation and Parsing} *)
247159248248-val parse_set_cookie :
160160+val of_set_cookie_header :
249161 clock:_ Eio.Time.clock -> domain:string -> path:string -> string -> t option
250250-(** Parse Set-Cookie header value into a cookie.
162162+(** Parse Set-Cookie response header value into a cookie.
163163+164164+ Set-Cookie headers are sent from server to client and contain the cookie
165165+ name, value, and all attributes.
251166252167 Parses a Set-Cookie header value following RFC specifications:
253168 - Basic format: [NAME=VALUE; attribute1; attribute2=value2]
···264179 - [Partitioned] requires the [Secure] flag to be set
265180266181 Example:
267267- [parse_set_cookie ~clock ~domain:"example.com" ~path:"/" "session=abc123;
182182+ [of_set_cookie_header ~clock ~domain:"example.com" ~path:"/" "session=abc123;
268183 Secure; HttpOnly"] *)
269184270185val of_cookie_header :
···273188 path:string ->
274189 string ->
275190 (t, string) result list
276276-(** Parse Cookie header containing semicolon-separated name=value pairs.
191191+(** Parse Cookie request header containing semicolon-separated name=value pairs.
277192278278- Cookie headers (client→server) contain only name=value pairs without
279279- attributes: ["name1=value1; name2=value2; name3=value3"]
193193+ Cookie headers are sent from client to server and contain only name=value
194194+ pairs without attributes: ["name1=value1; name2=value2; name3=value3"]
280195281196 Creates cookies with:
282197 - Provided [domain] and [path] from request context
···316231317232val pp : Format.formatter -> t -> unit
318233(** Pretty print a cookie *)
319319-320320-val pp_jar : Format.formatter -> jar -> unit
321321-(** Pretty print a cookie jar *)
322322-323323-(** {1 Mozilla Format} *)
324324-325325-val to_mozilla_format : jar -> string
326326-(** Write cookies in Mozilla format *)
327327-328328-val from_mozilla_format : clock:_ Eio.Time.clock -> string -> jar
329329-(** Parse Mozilla format cookies.
330330-331331- Creates a cookie jar from a string in Mozilla cookie format, using the
332332- provided clock to set creation and last access times. *)
+470
lib/core/cookeio.ml
···11+let src = Logs.Src.create "cookeio" ~doc:"Cookie management"
22+33+module Log = (val Logs.src_log src : Logs.LOG)
44+55+module SameSite = struct
66+ type t = [ `Strict | `Lax | `None ]
77+88+ let equal = ( = )
99+1010+ let pp ppf = function
1111+ | `Strict -> Format.pp_print_string ppf "Strict"
1212+ | `Lax -> Format.pp_print_string ppf "Lax"
1313+ | `None -> Format.pp_print_string ppf "None"
1414+end
1515+1616+module Expiration = struct
1717+ type t = [ `Session | `DateTime of Ptime.t ]
1818+1919+ let equal e1 e2 =
2020+ match (e1, e2) with
2121+ | `Session, `Session -> true
2222+ | `DateTime t1, `DateTime t2 -> Ptime.equal t1 t2
2323+ | _ -> false
2424+2525+ let pp ppf = function
2626+ | `Session -> Format.pp_print_string ppf "Session"
2727+ | `DateTime t -> Format.fprintf ppf "DateTime(%a)" Ptime.pp t
2828+end
2929+3030+type t = {
3131+ domain : string;
3232+ path : string;
3333+ name : string;
3434+ value : string;
3535+ secure : bool;
3636+ http_only : bool;
3737+ partitioned : bool;
3838+ expires : Expiration.t option;
3939+ max_age : Ptime.Span.t option;
4040+ same_site : SameSite.t option;
4141+ creation_time : Ptime.t;
4242+ last_access : Ptime.t;
4343+}
4444+(** HTTP Cookie *)
4545+4646+(** {1 Cookie Accessors} *)
4747+4848+let domain cookie = cookie.domain
4949+let path cookie = cookie.path
5050+let name cookie = cookie.name
5151+let value cookie = cookie.value
5252+5353+let value_trimmed cookie =
5454+ let v = cookie.value in
5555+ let len = String.length v in
5656+ if len < 2 then v
5757+ else
5858+ match (v.[0], v.[len - 1]) with
5959+ | '"', '"' -> String.sub v 1 (len - 2)
6060+ | _ -> v
6161+6262+let secure cookie = cookie.secure
6363+let http_only cookie = cookie.http_only
6464+let partitioned cookie = cookie.partitioned
6565+let expires cookie = cookie.expires
6666+let max_age cookie = cookie.max_age
6767+let same_site cookie = cookie.same_site
6868+let creation_time cookie = cookie.creation_time
6969+let last_access cookie = cookie.last_access
7070+7171+let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false)
7272+ ?expires ?max_age ?same_site ?(partitioned = false) ~creation_time
7373+ ~last_access () =
7474+ {
7575+ domain;
7676+ path;
7777+ name;
7878+ value;
7979+ secure;
8080+ http_only;
8181+ partitioned;
8282+ expires;
8383+ max_age;
8484+ same_site;
8585+ creation_time;
8686+ last_access;
8787+ }
8888+8989+(** {1 Cookie Parsing Helpers} *)
9090+9191+let normalize_domain domain =
9292+ (* Strip leading dot per RFC 6265 *)
9393+ match String.starts_with ~prefix:"." domain with
9494+ | true when String.length domain > 1 ->
9595+ String.sub domain 1 (String.length domain - 1)
9696+ | _ -> domain
9797+9898+(** {1 HTTP Date Parsing} *)
9999+100100+module DateParser = struct
101101+ (** Month name to number mapping (case-insensitive) *)
102102+ let month_of_string s =
103103+ match String.lowercase_ascii s with
104104+ | "jan" -> Some 1
105105+ | "feb" -> Some 2
106106+ | "mar" -> Some 3
107107+ | "apr" -> Some 4
108108+ | "may" -> Some 5
109109+ | "jun" -> Some 6
110110+ | "jul" -> Some 7
111111+ | "aug" -> Some 8
112112+ | "sep" -> Some 9
113113+ | "oct" -> Some 10
114114+ | "nov" -> Some 11
115115+ | "dec" -> Some 12
116116+ | _ -> None
117117+118118+ (** Normalize abbreviated years:
119119+ - Years 69-99 get 1900 added (e.g., 95 → 1995)
120120+ - Years 0-68 get 2000 added (e.g., 25 → 2025)
121121+ - Years >= 100 are returned as-is *)
122122+ let normalize_year year =
123123+ if year >= 0 && year <= 68 then year + 2000
124124+ else if year >= 69 && year <= 99 then year + 1900
125125+ else year
126126+127127+ (** Parse FMT1: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 1123) *)
128128+ let parse_fmt1 s =
129129+ try
130130+ Scanf.sscanf s "%s %d %s %d %d:%d:%d %s"
131131+ (fun _wday day mon year hour min sec tz ->
132132+ (* Check timezone is GMT (case-insensitive) *)
133133+ if String.lowercase_ascii tz <> "gmt" then None
134134+ else
135135+ match month_of_string mon with
136136+ | None -> None
137137+ | Some month ->
138138+ let year = normalize_year year in
139139+ Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
140140+ with _ -> None
141141+142142+ (** Parse FMT2: "Wednesday, 21-Oct-15 07:28:00 GMT" (RFC 850) *)
143143+ let parse_fmt2 s =
144144+ try
145145+ Scanf.sscanf s "%[^,], %d-%3s-%d %d:%d:%d %s"
146146+ (fun _wday day mon year hour min sec tz ->
147147+ (* Check timezone is GMT (case-insensitive) *)
148148+ if String.lowercase_ascii tz <> "gmt" then None
149149+ else
150150+ match month_of_string mon with
151151+ | None -> None
152152+ | Some month ->
153153+ let year = normalize_year year in
154154+ Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
155155+ with _ -> None
156156+157157+ (** Parse FMT3: "Wed Oct 21 07:28:00 2015" (asctime) *)
158158+ let parse_fmt3 s =
159159+ try
160160+ Scanf.sscanf s "%s %s %d %d:%d:%d %d"
161161+ (fun _wday mon day hour min sec year ->
162162+ match month_of_string mon with
163163+ | None -> None
164164+ | Some month ->
165165+ let year = normalize_year year in
166166+ Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
167167+ with _ -> None
168168+169169+ (** Parse FMT4: "Wed, 21-Oct-2015 07:28:00 GMT" (variant) *)
170170+ let parse_fmt4 s =
171171+ try
172172+ Scanf.sscanf s "%s %d-%3s-%d %d:%d:%d %s"
173173+ (fun _wday day mon year hour min sec tz ->
174174+ (* Check timezone is GMT (case-insensitive) *)
175175+ if String.lowercase_ascii tz <> "gmt" then None
176176+ else
177177+ match month_of_string mon with
178178+ | None -> None
179179+ | Some month ->
180180+ let year = normalize_year year in
181181+ Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0)))
182182+ with _ -> None
183183+184184+ (** Parse HTTP date by trying all supported formats in sequence *)
185185+ let parse_http_date s =
186186+ match parse_fmt1 s with
187187+ | Some t -> Some t
188188+ | None -> (
189189+ match parse_fmt2 s with
190190+ | Some t -> Some t
191191+ | None -> (
192192+ match parse_fmt3 s with Some t -> Some t | None -> parse_fmt4 s))
193193+end
194194+195195+(** {1 Cookie Parsing} *)
196196+197197+type cookie_attributes = {
198198+ mutable domain : string option;
199199+ mutable path : string option;
200200+ mutable secure : bool;
201201+ mutable http_only : bool;
202202+ mutable partitioned : bool;
203203+ mutable expires : Expiration.t option;
204204+ mutable max_age : Ptime.Span.t option;
205205+ mutable same_site : SameSite.t option;
206206+}
207207+(** Accumulated attributes from parsing Set-Cookie header *)
208208+209209+(** Create empty attribute accumulator *)
210210+let empty_attributes () =
211211+ {
212212+ domain = None;
213213+ path = None;
214214+ secure = false;
215215+ http_only = false;
216216+ partitioned = false;
217217+ expires = None;
218218+ max_age = None;
219219+ same_site = None;
220220+ }
221221+222222+(** Parse a single attribute and update the accumulator in-place *)
223223+let parse_attribute clock attrs attr_name attr_value =
224224+ let attr_lower = String.lowercase_ascii attr_name in
225225+ match attr_lower with
226226+ | "domain" -> attrs.domain <- Some (normalize_domain attr_value)
227227+ | "path" -> attrs.path <- Some attr_value
228228+ | "expires" -> (
229229+ (* Special case: Expires=0 means session cookie *)
230230+ if attr_value = "0" then attrs.expires <- Some `Session
231231+ else
232232+ match Ptime.of_rfc3339 attr_value with
233233+ | Ok (time, _, _) -> attrs.expires <- Some (`DateTime time)
234234+ | Error (`RFC3339 (_, err)) -> (
235235+ (* Try HTTP date format as fallback *)
236236+ match DateParser.parse_http_date attr_value with
237237+ | Some time -> attrs.expires <- Some (`DateTime time)
238238+ | None ->
239239+ Log.warn (fun m ->
240240+ m "Failed to parse expires attribute '%s': %a" attr_value
241241+ Ptime.pp_rfc3339_error err)))
242242+ | "max-age" -> (
243243+ match int_of_string_opt attr_value with
244244+ | Some seconds ->
245245+ (* Handle negative values as 0 per RFC 6265 *)
246246+ let seconds = max 0 seconds in
247247+ let now = Eio.Time.now clock in
248248+ (* Store the max-age as a Ptime.Span *)
249249+ attrs.max_age <- Some (Ptime.Span.of_int_s seconds);
250250+ (* Also compute and store expires as DateTime *)
251251+ let expires = Ptime.of_float_s (now +. float_of_int seconds) in
252252+ (match expires with
253253+ | Some time -> attrs.expires <- Some (`DateTime time)
254254+ | None -> ());
255255+ Log.debug (fun m -> m "Parsed Max-Age: %d seconds" seconds)
256256+ | None ->
257257+ Log.warn (fun m ->
258258+ m "Failed to parse max-age attribute '%s'" attr_value))
259259+ | "secure" -> attrs.secure <- true
260260+ | "httponly" -> attrs.http_only <- true
261261+ | "partitioned" -> attrs.partitioned <- true
262262+ | "samesite" -> (
263263+ match String.lowercase_ascii attr_value with
264264+ | "strict" -> attrs.same_site <- Some `Strict
265265+ | "lax" -> attrs.same_site <- Some `Lax
266266+ | "none" -> attrs.same_site <- Some `None
267267+ | _ ->
268268+ Log.warn (fun m ->
269269+ m "Invalid samesite value '%s', ignoring" attr_value))
270270+ | _ ->
271271+ Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
272272+273273+(** Validate cookie attributes and log warnings for invalid combinations *)
274274+let validate_attributes attrs =
275275+ (* SameSite=None requires Secure flag *)
276276+ let samesite_valid =
277277+ match attrs.same_site with
278278+ | Some `None when not attrs.secure ->
279279+ Log.warn (fun m ->
280280+ m
281281+ "Cookie has SameSite=None but Secure flag is not set; this \
282282+ violates RFC requirements");
283283+ false
284284+ | _ -> true
285285+ in
286286+ (* Partitioned requires Secure flag *)
287287+ let partitioned_valid =
288288+ if attrs.partitioned && not attrs.secure then (
289289+ Log.warn (fun m ->
290290+ m
291291+ "Cookie has Partitioned attribute but Secure flag is not set; \
292292+ this violates CHIPS requirements");
293293+ false)
294294+ else true
295295+ in
296296+ samesite_valid && partitioned_valid
297297+298298+(** Build final cookie from name/value and accumulated attributes *)
299299+let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
300300+ let domain =
301301+ normalize_domain (Option.value attrs.domain ~default:request_domain)
302302+ in
303303+ let path = Option.value attrs.path ~default:request_path in
304304+ make ~domain ~path ~name ~value ~secure:attrs.secure
305305+ ~http_only:attrs.http_only ?expires:attrs.expires ?max_age:attrs.max_age
306306+ ?same_site:attrs.same_site ~partitioned:attrs.partitioned
307307+ ~creation_time:now ~last_access:now ()
308308+309309+(** {1 Pretty Printing} *)
310310+311311+let pp ppf cookie =
312312+ Format.fprintf ppf
313313+ "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \
314314+ http_only=%b;@ partitioned=%b;@ expires=%a;@ max_age=%a;@ same_site=%a }@]"
315315+ (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie)
316316+ (http_only cookie) (partitioned cookie)
317317+ (Format.pp_print_option Expiration.pp)
318318+ (expires cookie)
319319+ (Format.pp_print_option Ptime.Span.pp)
320320+ (max_age cookie)
321321+ (Format.pp_print_option SameSite.pp)
322322+ (same_site cookie)
323323+324324+(** {1 Cookie Parsing} *)
325325+326326+let of_set_cookie_header ~clock ~domain:request_domain ~path:request_path
327327+ header_value =
328328+ Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
329329+330330+ (* Split into attributes *)
331331+ let parts = String.split_on_char ';' header_value |> List.map String.trim in
332332+333333+ match parts with
334334+ | [] -> None
335335+ | name_value :: attrs -> (
336336+ (* Parse name=value *)
337337+ match String.index_opt name_value '=' with
338338+ | None -> None
339339+ | Some eq_pos ->
340340+ let name = String.sub name_value 0 eq_pos |> String.trim in
341341+ let cookie_value =
342342+ String.sub name_value (eq_pos + 1)
343343+ (String.length name_value - eq_pos - 1)
344344+ |> String.trim
345345+ in
346346+347347+ let now =
348348+ Ptime.of_float_s (Eio.Time.now clock)
349349+ |> Option.value ~default:Ptime.epoch
350350+ in
351351+352352+ (* Parse all attributes into mutable accumulator *)
353353+ let accumulated_attrs = empty_attributes () in
354354+ List.iter
355355+ (fun attr ->
356356+ match String.index_opt attr '=' with
357357+ | None ->
358358+ (* Attribute without value (e.g., Secure, HttpOnly) *)
359359+ parse_attribute clock accumulated_attrs attr ""
360360+ | Some eq ->
361361+ let attr_name = String.sub attr 0 eq |> String.trim in
362362+ let attr_value =
363363+ String.sub attr (eq + 1) (String.length attr - eq - 1)
364364+ |> String.trim
365365+ in
366366+ parse_attribute clock accumulated_attrs attr_name attr_value)
367367+ attrs;
368368+369369+ (* Validate attributes *)
370370+ if not (validate_attributes accumulated_attrs) then (
371371+ Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
372372+ None)
373373+ else
374374+ let cookie =
375375+ build_cookie ~request_domain ~request_path ~name
376376+ ~value:cookie_value accumulated_attrs ~now
377377+ in
378378+ Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
379379+ Some cookie)
380380+381381+let of_cookie_header ~clock ~domain ~path header_value =
382382+ Log.debug (fun m -> m "Parsing Cookie header: %s" header_value);
383383+384384+ (* Split on semicolons *)
385385+ let parts = String.split_on_char ';' header_value |> List.map String.trim in
386386+387387+ (* Filter out empty parts *)
388388+ let parts = List.filter (fun s -> String.length s > 0) parts in
389389+390390+ (* Parse each name=value pair *)
391391+ List.map
392392+ (fun name_value ->
393393+ match String.index_opt name_value '=' with
394394+ | None ->
395395+ Error (Printf.sprintf "Cookie missing '=' separator: %s" name_value)
396396+ | Some eq_pos ->
397397+ let cookie_name = String.sub name_value 0 eq_pos |> String.trim in
398398+ if String.length cookie_name = 0 then
399399+ Error "Cookie has empty name"
400400+ else
401401+ let cookie_value =
402402+ String.sub name_value (eq_pos + 1)
403403+ (String.length name_value - eq_pos - 1)
404404+ |> String.trim
405405+ in
406406+ let now =
407407+ Ptime.of_float_s (Eio.Time.now clock)
408408+ |> Option.value ~default:Ptime.epoch
409409+ in
410410+ (* Create cookie with defaults from Cookie header context *)
411411+ let cookie =
412412+ make ~domain ~path ~name:cookie_name ~value:cookie_value
413413+ ~secure:false ~http_only:false ~partitioned:false ~creation_time:now
414414+ ~last_access:now ()
415415+ in
416416+ Ok cookie)
417417+ parts
418418+419419+let make_cookie_header cookies =
420420+ cookies
421421+ |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c))
422422+ |> String.concat "; "
423423+424424+let make_set_cookie_header cookie =
425425+ let buffer = Buffer.create 128 in
426426+ Buffer.add_string buffer (Printf.sprintf "%s=%s" (name cookie) (value cookie));
427427+428428+ (* Add Max-Age if present *)
429429+ (match max_age cookie with
430430+ | Some span -> (
431431+ match Ptime.Span.to_int_s span with
432432+ | Some seconds ->
433433+ Buffer.add_string buffer (Printf.sprintf "; Max-Age=%d" seconds)
434434+ | None -> ())
435435+ | None -> ());
436436+437437+ (* Add Expires if present *)
438438+ (match expires cookie with
439439+ | Some `Session ->
440440+ (* Session cookies can be indicated with Expires=0 or a past date *)
441441+ Buffer.add_string buffer "; Expires=0"
442442+ | Some (`DateTime exp_time) ->
443443+ (* Format as HTTP date *)
444444+ let exp_str = Ptime.to_rfc3339 ~tz_offset_s:0 exp_time in
445445+ Buffer.add_string buffer (Printf.sprintf "; Expires=%s" exp_str)
446446+ | None -> ());
447447+448448+ (* Add Domain *)
449449+ Buffer.add_string buffer (Printf.sprintf "; Domain=%s" (domain cookie));
450450+451451+ (* Add Path *)
452452+ Buffer.add_string buffer (Printf.sprintf "; Path=%s" (path cookie));
453453+454454+ (* Add Secure flag *)
455455+ if secure cookie then Buffer.add_string buffer "; Secure";
456456+457457+ (* Add HttpOnly flag *)
458458+ if http_only cookie then Buffer.add_string buffer "; HttpOnly";
459459+460460+ (* Add Partitioned flag *)
461461+ if partitioned cookie then Buffer.add_string buffer "; Partitioned";
462462+463463+ (* Add SameSite *)
464464+ (match same_site cookie with
465465+ | Some `Strict -> Buffer.add_string buffer "; SameSite=Strict"
466466+ | Some `Lax -> Buffer.add_string buffer "; SameSite=Lax"
467467+ | Some `None -> Buffer.add_string buffer "; SameSite=None"
468468+ | None -> ());
469469+470470+ Buffer.contents buffer
lib/dune
lib/core/dune
+434
lib/jar/cookeio_jar.ml
···11+let src = Logs.Src.create "cookie_jar" ~doc:"Cookie jar management"
22+33+module Log = (val Logs.src_log src : Logs.LOG)
44+55+type t = {
66+ mutable original_cookies : Cookeio.t list; (* from client *)
77+ mutable delta_cookies : Cookeio.t list; (* to send back *)
88+ mutex : Eio.Mutex.t;
99+}
1010+(** Cookie jar for storing and managing cookies *)
1111+1212+(** {1 Cookie Jar Creation} *)
1313+1414+let create () =
1515+ Log.debug (fun m -> m "Creating new empty cookie jar");
1616+ { original_cookies = []; delta_cookies = []; mutex = Eio.Mutex.create () }
1717+1818+(** {1 Cookie Matching Helpers} *)
1919+2020+let cookie_identity_matches c1 c2 =
2121+ Cookeio.name c1 = Cookeio.name c2
2222+ && Cookeio.domain c1 = Cookeio.domain c2
2323+ && Cookeio.path c1 = Cookeio.path c2
2424+2525+let normalize_domain domain =
2626+ (* Strip leading dot per RFC 6265 *)
2727+ match String.starts_with ~prefix:"." domain with
2828+ | true when String.length domain > 1 ->
2929+ String.sub domain 1 (String.length domain - 1)
3030+ | _ -> domain
3131+3232+let domain_matches cookie_domain request_domain =
3333+ (* Cookie domains are stored without leading dots per RFC 6265.
3434+ A cookie with domain "example.com" should match both "example.com" (exact)
3535+ and "sub.example.com" (subdomain). *)
3636+ request_domain = cookie_domain
3737+ || String.ends_with ~suffix:("." ^ cookie_domain) request_domain
3838+3939+let path_matches cookie_path request_path =
4040+ (* Cookie path /foo matches /foo, /foo/, /foo/bar *)
4141+ String.starts_with ~prefix:cookie_path request_path
4242+4343+(** {1 HTTP Date Parsing} *)
4444+let is_expired cookie clock =
4545+ match Cookeio.expires cookie with
4646+ | None -> false (* No expiration *)
4747+ | Some `Session -> false (* Session cookie - not expired until browser closes *)
4848+ | Some (`DateTime exp_time) ->
4949+ let now =
5050+ Ptime.of_float_s (Eio.Time.now clock)
5151+ |> Option.value ~default:Ptime.epoch
5252+ in
5353+ Ptime.compare now exp_time > 0
5454+5555+let pp ppf jar =
5656+ Eio.Mutex.lock jar.mutex;
5757+ let original = jar.original_cookies in
5858+ let delta = jar.delta_cookies in
5959+ Eio.Mutex.unlock jar.mutex;
6060+6161+ let all_cookies = original @ delta in
6262+ Format.fprintf ppf "@[<v>CookieJar with %d cookies (%d original, %d delta):@,"
6363+ (List.length all_cookies) (List.length original) (List.length delta);
6464+ List.iter
6565+ (fun cookie -> Format.fprintf ppf " %a@," Cookeio.pp cookie)
6666+ all_cookies;
6767+ Format.fprintf ppf "@]"
6868+6969+(** {1 Cookie Management} *)
7070+7171+let add_cookie jar cookie =
7272+ Log.debug (fun m ->
7373+ m "Adding cookie to delta: %s=%s for domain %s"
7474+ (Cookeio.name cookie)
7575+ (Cookeio.value cookie)
7676+ (Cookeio.domain cookie));
7777+7878+ Eio.Mutex.lock jar.mutex;
7979+ (* Remove existing cookie with same identity from delta *)
8080+ jar.delta_cookies <-
8181+ List.filter
8282+ (fun c -> not (cookie_identity_matches c cookie))
8383+ jar.delta_cookies;
8484+ jar.delta_cookies <- cookie :: jar.delta_cookies;
8585+ Eio.Mutex.unlock jar.mutex
8686+8787+let add_original jar cookie =
8888+ Log.debug (fun m ->
8989+ m "Adding original cookie: %s=%s for domain %s"
9090+ (Cookeio.name cookie)
9191+ (Cookeio.value cookie)
9292+ (Cookeio.domain cookie));
9393+9494+ Eio.Mutex.lock jar.mutex;
9595+ (* Remove existing cookie with same identity from original *)
9696+ jar.original_cookies <-
9797+ List.filter
9898+ (fun c -> not (cookie_identity_matches c cookie))
9999+ jar.original_cookies;
100100+ jar.original_cookies <- cookie :: jar.original_cookies;
101101+ Eio.Mutex.unlock jar.mutex
102102+103103+let delta jar =
104104+ Eio.Mutex.lock jar.mutex;
105105+ let result = jar.delta_cookies in
106106+ Eio.Mutex.unlock jar.mutex;
107107+ Log.debug (fun m -> m "Returning %d delta cookies" (List.length result));
108108+ result
109109+110110+let make_removal_cookie cookie ~clock =
111111+ let now =
112112+ Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
113113+ in
114114+ (* Create a cookie with Max-Age=0 and past expiration (1 year ago) *)
115115+ let past_expiry =
116116+ Ptime.sub_span now (Ptime.Span.of_int_s (365 * 24 * 60 * 60))
117117+ |> Option.value ~default:Ptime.epoch
118118+ in
119119+ Cookeio.make
120120+ ~domain:(Cookeio.domain cookie)
121121+ ~path:(Cookeio.path cookie)
122122+ ~name:(Cookeio.name cookie)
123123+ ~value:""
124124+ ~secure:(Cookeio.secure cookie)
125125+ ~http_only:(Cookeio.http_only cookie)
126126+ ~expires:(`DateTime past_expiry)
127127+ ~max_age:(Ptime.Span.of_int_s 0)
128128+ ?same_site:(Cookeio.same_site cookie)
129129+ ~partitioned:(Cookeio.partitioned cookie)
130130+ ~creation_time:now ~last_access:now ()
131131+132132+let remove jar ~clock cookie =
133133+ Log.debug (fun m ->
134134+ m "Removing cookie: %s=%s for domain %s"
135135+ (Cookeio.name cookie)
136136+ (Cookeio.value cookie)
137137+ (Cookeio.domain cookie));
138138+139139+ Eio.Mutex.lock jar.mutex;
140140+ (* Check if this cookie exists in original_cookies *)
141141+ let in_original =
142142+ List.exists (fun c -> cookie_identity_matches c cookie) jar.original_cookies
143143+ in
144144+145145+ if in_original then (
146146+ (* Create a removal cookie and add it to delta *)
147147+ let removal = make_removal_cookie cookie ~clock in
148148+ jar.delta_cookies <-
149149+ List.filter
150150+ (fun c -> not (cookie_identity_matches c removal))
151151+ jar.delta_cookies;
152152+ jar.delta_cookies <- removal :: jar.delta_cookies;
153153+ Log.debug (fun m -> m "Created removal cookie in delta for original cookie"))
154154+ else (
155155+ (* Just remove from delta if it exists there *)
156156+ jar.delta_cookies <-
157157+ List.filter
158158+ (fun c -> not (cookie_identity_matches c cookie))
159159+ jar.delta_cookies;
160160+ Log.debug (fun m -> m "Removed cookie from delta"));
161161+162162+ Eio.Mutex.unlock jar.mutex
163163+164164+let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure
165165+ =
166166+ Log.debug (fun m ->
167167+ m "Getting cookies for domain=%s path=%s secure=%b" request_domain
168168+ request_path is_secure);
169169+170170+ Eio.Mutex.lock jar.mutex;
171171+172172+ (* Combine original and delta cookies, with delta taking precedence *)
173173+ let all_cookies = jar.original_cookies @ jar.delta_cookies in
174174+175175+ (* Filter out duplicates, keeping the last occurrence (from delta) *)
176176+ let rec dedup acc = function
177177+ | [] -> List.rev acc
178178+ | c :: rest ->
179179+ (* Keep this cookie only if no later cookie has the same identity *)
180180+ let has_duplicate =
181181+ List.exists (fun c2 -> cookie_identity_matches c c2) rest
182182+ in
183183+ if has_duplicate then dedup acc rest else dedup (c :: acc) rest
184184+ in
185185+ let unique_cookies = dedup [] all_cookies in
186186+187187+ (* Filter for applicable cookies, excluding removal cookies (empty value) *)
188188+ let applicable =
189189+ List.filter
190190+ (fun cookie ->
191191+ Cookeio.value cookie <> ""
192192+ (* Exclude removal cookies *)
193193+ && domain_matches (Cookeio.domain cookie) request_domain
194194+ && path_matches (Cookeio.path cookie) request_path
195195+ && ((not (Cookeio.secure cookie)) || is_secure))
196196+ unique_cookies
197197+ in
198198+199199+ (* Update last access time in both lists *)
200200+ let now =
201201+ Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
202202+ in
203203+ let update_last_access cookies =
204204+ List.map
205205+ (fun c ->
206206+ if List.exists (fun a -> cookie_identity_matches a c) applicable then
207207+ Cookeio.make
208208+ ~domain:(Cookeio.domain c)
209209+ ~path:(Cookeio.path c)
210210+ ~name:(Cookeio.name c)
211211+ ~value:(Cookeio.value c)
212212+ ~secure:(Cookeio.secure c)
213213+ ~http_only:(Cookeio.http_only c)
214214+ ?expires:(Cookeio.expires c)
215215+ ?max_age:(Cookeio.max_age c)
216216+ ?same_site:(Cookeio.same_site c)
217217+ ~partitioned:(Cookeio.partitioned c)
218218+ ~creation_time:(Cookeio.creation_time c)
219219+ ~last_access:now ()
220220+ else c)
221221+ cookies
222222+ in
223223+ jar.original_cookies <- update_last_access jar.original_cookies;
224224+ jar.delta_cookies <- update_last_access jar.delta_cookies;
225225+226226+ Eio.Mutex.unlock jar.mutex;
227227+228228+ Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
229229+ applicable
230230+231231+let clear jar =
232232+ Log.info (fun m -> m "Clearing all cookies");
233233+ Eio.Mutex.lock jar.mutex;
234234+ jar.original_cookies <- [];
235235+ jar.delta_cookies <- [];
236236+ Eio.Mutex.unlock jar.mutex
237237+238238+let clear_expired jar ~clock =
239239+ Eio.Mutex.lock jar.mutex;
240240+ let before_count =
241241+ List.length jar.original_cookies + List.length jar.delta_cookies
242242+ in
243243+ jar.original_cookies <-
244244+ List.filter (fun c -> not (is_expired c clock)) jar.original_cookies;
245245+ jar.delta_cookies <-
246246+ List.filter (fun c -> not (is_expired c clock)) jar.delta_cookies;
247247+ let removed =
248248+ before_count
249249+ - (List.length jar.original_cookies + List.length jar.delta_cookies)
250250+ in
251251+ Eio.Mutex.unlock jar.mutex;
252252+ Log.info (fun m -> m "Cleared %d expired cookies" removed)
253253+254254+let clear_session_cookies jar =
255255+ Eio.Mutex.lock jar.mutex;
256256+ let before_count =
257257+ List.length jar.original_cookies + List.length jar.delta_cookies
258258+ in
259259+ (* Keep only cookies that are NOT session cookies *)
260260+ let is_not_session c =
261261+ match Cookeio.expires c with
262262+ | Some `Session -> false (* This is a session cookie, remove it *)
263263+ | None | Some (`DateTime _) -> true (* Keep these *)
264264+ in
265265+ jar.original_cookies <- List.filter is_not_session jar.original_cookies;
266266+ jar.delta_cookies <- List.filter is_not_session jar.delta_cookies;
267267+ let removed =
268268+ before_count
269269+ - (List.length jar.original_cookies + List.length jar.delta_cookies)
270270+ in
271271+ Eio.Mutex.unlock jar.mutex;
272272+ Log.info (fun m -> m "Cleared %d session cookies" removed)
273273+274274+let count jar =
275275+ Eio.Mutex.lock jar.mutex;
276276+ (* Combine and deduplicate cookies for count *)
277277+ let all_cookies = jar.original_cookies @ jar.delta_cookies in
278278+ let rec dedup acc = function
279279+ | [] -> List.rev acc
280280+ | c :: rest ->
281281+ let has_duplicate =
282282+ List.exists (fun c2 -> cookie_identity_matches c c2) rest
283283+ in
284284+ if has_duplicate then dedup acc rest else dedup (c :: acc) rest
285285+ in
286286+ let unique = dedup [] all_cookies in
287287+ let n = List.length unique in
288288+ Eio.Mutex.unlock jar.mutex;
289289+ n
290290+291291+let get_all_cookies jar =
292292+ Eio.Mutex.lock jar.mutex;
293293+ (* Combine and deduplicate, with delta taking precedence *)
294294+ let all_cookies = jar.original_cookies @ jar.delta_cookies in
295295+ let rec dedup acc = function
296296+ | [] -> List.rev acc
297297+ | c :: rest ->
298298+ let has_duplicate =
299299+ List.exists (fun c2 -> cookie_identity_matches c c2) rest
300300+ in
301301+ if has_duplicate then dedup acc rest else dedup (c :: acc) rest
302302+ in
303303+ let unique = dedup [] all_cookies in
304304+ Eio.Mutex.unlock jar.mutex;
305305+ unique
306306+307307+let is_empty jar =
308308+ Eio.Mutex.lock jar.mutex;
309309+ let empty = jar.original_cookies = [] && jar.delta_cookies = [] in
310310+ Eio.Mutex.unlock jar.mutex;
311311+ empty
312312+313313+(** {1 Mozilla Format} *)
314314+315315+let to_mozilla_format_internal jar =
316316+ let buffer = Buffer.create 1024 in
317317+ Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
318318+ Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
319319+320320+ (* Combine and deduplicate cookies *)
321321+ let all_cookies = jar.original_cookies @ jar.delta_cookies in
322322+ let rec dedup acc = function
323323+ | [] -> List.rev acc
324324+ | c :: rest ->
325325+ let has_duplicate =
326326+ List.exists (fun c2 -> cookie_identity_matches c c2) rest
327327+ in
328328+ if has_duplicate then dedup acc rest else dedup (c :: acc) rest
329329+ in
330330+ let unique = dedup [] all_cookies in
331331+332332+ List.iter
333333+ (fun cookie ->
334334+ let include_subdomains =
335335+ if String.starts_with ~prefix:"." (Cookeio.domain cookie) then "TRUE"
336336+ else "FALSE"
337337+ in
338338+ let secure_flag = if Cookeio.secure cookie then "TRUE" else "FALSE" in
339339+ let expires_str =
340340+ match Cookeio.expires cookie with
341341+ | None -> "0" (* No expiration *)
342342+ | Some `Session -> "0" (* Session cookie *)
343343+ | Some (`DateTime t) ->
344344+ let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
345345+ epoch
346346+ in
347347+348348+ Buffer.add_string buffer
349349+ (Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
350350+ (Cookeio.domain cookie)
351351+ include_subdomains
352352+ (Cookeio.path cookie)
353353+ secure_flag expires_str
354354+ (Cookeio.name cookie)
355355+ (Cookeio.value cookie)))
356356+ unique;
357357+358358+ Buffer.contents buffer
359359+360360+let to_mozilla_format jar =
361361+ Eio.Mutex.lock jar.mutex;
362362+ let result = to_mozilla_format_internal jar in
363363+ Eio.Mutex.unlock jar.mutex;
364364+ result
365365+366366+let from_mozilla_format ~clock content =
367367+ Log.debug (fun m -> m "Parsing Mozilla format cookies");
368368+ let jar = create () in
369369+370370+ let lines = String.split_on_char '\n' content in
371371+ List.iter
372372+ (fun line ->
373373+ let line = String.trim line in
374374+ if line <> "" && not (String.starts_with ~prefix:"#" line) then
375375+ match String.split_on_char '\t' line with
376376+ | [ domain; _include_subdomains; path; secure; expires; name; value ] ->
377377+ let now =
378378+ Ptime.of_float_s (Eio.Time.now clock)
379379+ |> Option.value ~default:Ptime.epoch
380380+ in
381381+ let expires =
382382+ let exp_int = try int_of_string expires with _ -> 0 in
383383+ if exp_int = 0 then None
384384+ else
385385+ match Ptime.of_float_s (float_of_int exp_int) with
386386+ | Some t -> Some (`DateTime t)
387387+ | None -> None
388388+ in
389389+390390+ let cookie =
391391+ Cookeio.make ~domain:(normalize_domain domain) ~path ~name ~value
392392+ ~secure:(secure = "TRUE") ~http_only:false ?expires ?max_age:None
393393+ ?same_site:None ~partitioned:false ~creation_time:now
394394+ ~last_access:now ()
395395+ in
396396+ add_original jar cookie;
397397+ Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
398398+ | _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line))
399399+ lines;
400400+401401+ Log.info (fun m -> m "Loaded %d cookies" (List.length jar.original_cookies));
402402+ jar
403403+404404+(** {1 File Operations} *)
405405+406406+let load ~clock path =
407407+ Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
408408+409409+ try
410410+ let content = Eio.Path.load path in
411411+ from_mozilla_format ~clock content
412412+ with
413413+ | Eio.Io _ ->
414414+ Log.info (fun m -> m "Cookie file not found, creating empty jar");
415415+ create ()
416416+ | exn ->
417417+ Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
418418+ create ()
419419+420420+let save path jar =
421421+ Eio.Mutex.lock jar.mutex;
422422+ let total_cookies =
423423+ List.length jar.original_cookies + List.length jar.delta_cookies
424424+ in
425425+ Eio.Mutex.unlock jar.mutex;
426426+ Log.info (fun m -> m "Saving %d cookies to %a" total_cookies Eio.Path.pp path);
427427+428428+ let content = to_mozilla_format jar in
429429+430430+ try
431431+ Eio.Path.save ~create:(`Or_truncate 0o600) path content;
432432+ Log.debug (fun m -> m "Cookies saved successfully")
433433+ with exn ->
434434+ Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+117
lib/jar/cookeio_jar.mli
···11+(** Cookie jar for storing and managing HTTP cookies.
22+33+ This module provides a complete cookie jar implementation following
44+ established web standards while integrating Eio for efficient asynchronous
55+ operations.
66+77+ A cookie jar maintains a collection of cookies with automatic cleanup of
88+ expired entries. It implements the standard browser behavior for cookie
99+ storage, including:
1010+ - Automatic removal of expired cookies
1111+ - Domain and path-based cookie retrieval
1212+ - Delta tracking for Set-Cookie headers
1313+ - Mozilla format persistence for cross-tool compatibility *)
1414+1515+type t
1616+(** Cookie jar for storing and managing cookies.
1717+1818+ A cookie jar maintains a collection of cookies with automatic cleanup of
1919+ expired entries and enforcement of storage limits. It implements the
2020+ standard browser behavior for cookie storage, including:
2121+ - Automatic removal of expired cookies
2222+ - LRU eviction when storage limits are exceeded
2323+ - Domain and path-based cookie retrieval
2424+ - Mozilla format persistence for cross-tool compatibility *)
2525+2626+(** {1 Cookie Jar Creation and Loading} *)
2727+2828+val create : unit -> t
2929+(** Create an empty cookie jar *)
3030+3131+val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> t
3232+(** Load cookies from Mozilla format file.
3333+3434+ Loads cookies from a file in Mozilla format, using the provided clock to set
3535+ creation and last access times. Returns an empty jar if the file doesn't
3636+ exist or cannot be loaded. *)
3737+3838+val save : Eio.Fs.dir_ty Eio.Path.t -> t -> unit
3939+(** Save cookies to Mozilla format file *)
4040+4141+(** {1 Cookie Jar Management} *)
4242+4343+val add_cookie : t -> Cookeio.t -> unit
4444+(** Add a cookie to the jar.
4545+4646+ The cookie is added to the delta, meaning it will appear in Set-Cookie
4747+ headers when calling {!delta}. If a cookie with the same name/domain/path
4848+ exists in the delta, it will be replaced. *)
4949+5050+val add_original : t -> Cookeio.t -> unit
5151+(** Add an original cookie to the jar.
5252+5353+ Original cookies are those received from the client (via Cookie header).
5454+ They do not appear in the delta. This method should be used when loading
5555+ cookies from incoming HTTP requests. *)
5656+5757+val delta : t -> Cookeio.t list
5858+(** Get cookies that need to be sent in Set-Cookie headers.
5959+6060+ Returns cookies that have been added via {!add_cookie} and removal cookies
6161+ for original cookies that have been removed. Does not include original
6262+ cookies that were added via {!add_original}. *)
6363+6464+val remove : t -> clock:_ Eio.Time.clock -> Cookeio.t -> unit
6565+(** Remove a cookie from the jar.
6666+6767+ If an original cookie with the same name/domain/path exists, creates a
6868+ removal cookie (empty value, Max-Age=0, past expiration) that appears in the
6969+ delta. If only a delta cookie exists, simply removes it from the delta. *)
7070+7171+val get_cookies :
7272+ t ->
7373+ clock:_ Eio.Time.clock ->
7474+ domain:string ->
7575+ path:string ->
7676+ is_secure:bool ->
7777+ Cookeio.t list
7878+(** Get cookies applicable for a URL.
7979+8080+ Returns all cookies that match the given domain and path, and satisfy the
8181+ secure flag requirement. Combines original and delta cookies, with delta
8282+ taking precedence. Excludes removal cookies (empty value). Also updates the
8383+ last access time of matching cookies using the provided clock. *)
8484+8585+val clear : t -> unit
8686+(** Clear all cookies *)
8787+8888+val clear_expired : t -> clock:_ Eio.Time.clock -> unit
8989+(** Clear expired cookies *)
9090+9191+val clear_session_cookies : t -> unit
9292+(** Clear session cookies (those without expiry) *)
9393+9494+val count : t -> int
9595+(** Get the number of cookies in the jar *)
9696+9797+val get_all_cookies : t -> Cookeio.t list
9898+(** Get all cookies in the jar *)
9999+100100+val is_empty : t -> bool
101101+(** Check if the jar is empty *)
102102+103103+(** {1 Pretty Printing} *)
104104+105105+val pp : Format.formatter -> t -> unit
106106+(** Pretty print a cookie jar *)
107107+108108+(** {1 Mozilla Format} *)
109109+110110+val to_mozilla_format : t -> string
111111+(** Write cookies in Mozilla format *)
112112+113113+val from_mozilla_format : clock:_ Eio.Time.clock -> string -> t
114114+(** Parse Mozilla format cookies.
115115+116116+ Creates a cookie jar from a string in Mozilla cookie format, using the
117117+ provided clock to set creation and last access times. *)