···71717272(** {1 Cookie Parsing} *)
73737474-let parse_cookie_attribute attr attr_value cookie =
7575- let attr_lower = String.lowercase_ascii attr in
7474+(** Accumulated attributes from parsing Set-Cookie header *)
7575+type cookie_attributes = {
7676+ mutable domain : string option;
7777+ mutable path : string option;
7878+ mutable secure : bool;
7979+ mutable http_only : bool;
8080+ mutable expires : Ptime.t option;
8181+ mutable same_site : same_site option;
8282+}
8383+8484+(** Create empty attribute accumulator *)
8585+let empty_attributes () =
8686+ {
8787+ domain = None;
8888+ path = None;
8989+ secure = false;
9090+ http_only = false;
9191+ expires = None;
9292+ same_site = None;
9393+ }
9494+9595+(** Parse a single attribute and update the accumulator in-place *)
9696+let parse_attribute clock attrs attr_name attr_value =
9797+ let attr_lower = String.lowercase_ascii attr_name in
7698 match attr_lower with
7777- | "domain" -> make ~domain:attr_value ~path:(path cookie) ~name:(name cookie)
7878- ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
7979- ?expires:(expires cookie) ?same_site:(same_site cookie)
8080- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
8181- | "path" -> make ~domain:(domain cookie) ~path:attr_value ~name:(name cookie)
8282- ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
8383- ?expires:(expires cookie) ?same_site:(same_site cookie)
8484- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
9999+ | "domain" -> attrs.domain <- Some attr_value
100100+ | "path" -> attrs.path <- Some attr_value
85101 | "expires" -> (
8686- try
8787- let time, _tz_offset, _tz_string =
8888- Ptime.of_rfc3339 attr_value |> Result.get_ok
8989- in
9090- make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
9191- ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
9292- ~expires:time ?same_site:(same_site cookie)
9393- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
9494- with _ ->
9595- Log.debug (fun m -> m "Failed to parse expires: %s" attr_value);
9696- cookie)
102102+ match Ptime.of_rfc3339 attr_value with
103103+ | Ok (time, _, _) -> attrs.expires <- Some time
104104+ | Error (`RFC3339 (_, err)) ->
105105+ Log.warn (fun m ->
106106+ m "Failed to parse expires attribute '%s': %a" attr_value
107107+ Ptime.pp_rfc3339_error err))
97108 | "max-age" -> (
9898- try
9999- let seconds = int_of_string attr_value in
100100- let now = Unix.time () in
101101- let expires = Ptime.of_float_s (now +. float_of_int seconds) in
102102- make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
103103- ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
104104- ?expires ?same_site:(same_site cookie)
105105- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
106106- with _ -> cookie)
107107- | "secure" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
108108- ~value:(value cookie) ~secure:true ~http_only:(http_only cookie)
109109- ?expires:(expires cookie) ?same_site:(same_site cookie)
110110- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
111111- | "httponly" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
112112- ~value:(value cookie) ~secure:(secure cookie) ~http_only:true
113113- ?expires:(expires cookie) ?same_site:(same_site cookie)
114114- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
115115- | "samesite" ->
116116- let same_site_val =
117117- match String.lowercase_ascii attr_value with
118118- | "strict" -> Some `Strict
119119- | "lax" -> Some `Lax
120120- | "none" -> Some `None
121121- | _ -> None
122122- in
123123- make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie)
124124- ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie)
125125- ?expires:(expires cookie) ?same_site:same_site_val
126126- ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) ()
127127- | _ -> cookie
109109+ match int_of_string_opt attr_value with
110110+ | Some seconds ->
111111+ let now = Eio.Time.now clock in
112112+ let expires = Ptime.of_float_s (now +. float_of_int seconds) in
113113+ attrs.expires <- expires
114114+ | None ->
115115+ Log.warn (fun m -> m "Failed to parse max-age attribute '%s'" attr_value))
116116+ | "secure" -> attrs.secure <- true
117117+ | "httponly" -> attrs.http_only <- true
118118+ | "samesite" -> (
119119+ match String.lowercase_ascii attr_value with
120120+ | "strict" -> attrs.same_site <- Some `Strict
121121+ | "lax" -> attrs.same_site <- Some `Lax
122122+ | "none" -> attrs.same_site <- Some `None
123123+ | _ ->
124124+ Log.warn (fun m -> m "Invalid samesite value '%s', ignoring" attr_value))
125125+ | _ ->
126126+ Log.debug (fun m -> m "Unknown cookie attribute '%s', ignoring" attr_name)
127127+128128+(** Validate cookie attributes and log warnings for invalid combinations *)
129129+let validate_attributes attrs =
130130+ (* SameSite=None requires Secure flag *)
131131+ match attrs.same_site with
132132+ | Some `None when not attrs.secure ->
133133+ Log.warn (fun m ->
134134+ m
135135+ "Cookie has SameSite=None but Secure flag is not set; this violates \
136136+ RFC requirements");
137137+ false
138138+ | _ -> true
139139+140140+(** Build final cookie from name/value and accumulated attributes *)
141141+let build_cookie ~request_domain ~request_path ~name ~value attrs ~now =
142142+ let domain = Option.value attrs.domain ~default:request_domain in
143143+ let path = Option.value attrs.path ~default:request_path in
144144+ make ~domain ~path ~name ~value ~secure:attrs.secure ~http_only:attrs.http_only
145145+ ?expires:attrs.expires ?same_site:attrs.same_site ~creation_time:now
146146+ ~last_access:now ()
128147129129-let rec parse_set_cookie ~domain:request_domain ~path:request_path header_value =
148148+let rec parse_set_cookie ~clock ~domain:request_domain ~path:request_path
149149+ header_value =
130150 Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value);
131151132152 (* Split into attributes *)
···147167 in
148168149169 let now =
150150- Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch
151151- in
152152- let base_cookie =
153153- make ~domain:request_domain ~path:request_path ~name ~value:cookie_value ~secure:false ~http_only:false
154154- ?expires:None ?same_site:None ~creation_time:now ~last_access:now ()
170170+ Ptime.of_float_s (Eio.Time.now clock)
171171+ |> Option.value ~default:Ptime.epoch
155172 in
156173157157- (* Parse attributes *)
158158- let cookie =
159159- List.fold_left
160160- (fun cookie attr ->
161161- match String.index_opt attr '=' with
162162- | None -> parse_cookie_attribute attr "" cookie
163163- | Some eq ->
164164- let attr_name = String.sub attr 0 eq |> String.trim in
165165- let attr_value =
166166- String.sub attr (eq + 1) (String.length attr - eq - 1)
167167- |> String.trim
168168- in
169169- parse_cookie_attribute attr_name attr_value cookie)
170170- base_cookie attrs
171171- in
174174+ (* Parse all attributes into mutable accumulator *)
175175+ let accumulated_attrs = empty_attributes () in
176176+ List.iter
177177+ (fun attr ->
178178+ match String.index_opt attr '=' with
179179+ | None ->
180180+ (* Attribute without value (e.g., Secure, HttpOnly) *)
181181+ parse_attribute clock accumulated_attrs attr ""
182182+ | Some eq ->
183183+ let attr_name = String.sub attr 0 eq |> String.trim in
184184+ let attr_value =
185185+ String.sub attr (eq + 1) (String.length attr - eq - 1)
186186+ |> String.trim
187187+ in
188188+ parse_attribute clock accumulated_attrs attr_name attr_value)
189189+ attrs;
172190173173- Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
174174- Some cookie)
191191+ (* Validate attributes *)
192192+ if not (validate_attributes accumulated_attrs) then (
193193+ Log.warn (fun m -> m "Cookie validation failed, rejecting cookie");
194194+ None)
195195+ else
196196+ let cookie =
197197+ build_cookie ~request_domain ~request_path ~name ~value:cookie_value
198198+ accumulated_attrs ~now
199199+ in
200200+ Log.debug (fun m -> m "Parsed cookie: %a" pp cookie);
201201+ Some cookie)
175202176203and make_cookie_header cookies =
177204 cookies
···224251 jar.cookies <- cookie :: jar.cookies;
225252 Eio.Mutex.unlock jar.mutex
226253227227-let get_cookies jar ~domain:request_domain ~path:request_path ~is_secure =
254254+let get_cookies jar ~clock ~domain:request_domain ~path:request_path ~is_secure =
228255 Log.debug (fun m ->
229229- m "Getting cookies for domain=%s path=%s secure=%b" request_domain request_path is_secure);
256256+ m "Getting cookies for domain=%s path=%s secure=%b" request_domain
257257+ request_path is_secure);
230258231259 Eio.Mutex.lock jar.mutex;
232260 let applicable =
···240268241269 (* Update last access time *)
242270 let now =
243243- Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch
271271+ Ptime.of_float_s (Eio.Time.now clock) |> Option.value ~default:Ptime.epoch
244272 in
245273 let updated =
246274 List.map
···248276 if List.memq c applicable then
249277 make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c)
250278 ~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c)
251251- ?same_site:(same_site c) ~creation_time:(creation_time c) ~last_access:now ()
279279+ ?same_site:(same_site c) ~creation_time:(creation_time c)
280280+ ~last_access:now ()
252281 else c)
253282 jar.cookies
254283 in
···333362 Eio.Mutex.unlock jar.mutex;
334363 result
335364336336-let from_mozilla_format content =
365365+let from_mozilla_format ~clock content =
337366 Log.debug (fun m -> m "Parsing Mozilla format cookies");
338367 let jar = create () in
339368···345374 match String.split_on_char '\t' line with
346375 | [ domain; _include_subdomains; path; secure; expires; name; value ] ->
347376 let now =
348348- Ptime.of_float_s (Unix.time ())
377377+ Ptime.of_float_s (Eio.Time.now clock)
349378 |> Option.value ~default:Ptime.epoch
350379 in
351380 let expires =
352381 let exp_int = try int_of_string expires with _ -> 0 in
353353- if exp_int = 0 then None
354354- else Ptime.of_float_s (float_of_int exp_int)
382382+ if exp_int = 0 then None else Ptime.of_float_s (float_of_int exp_int)
355383 in
356384357385 let cookie =
358358- make ~domain ~path ~name ~value
359359- ~secure:(secure = "TRUE") ~http_only:false
360360- ?expires ?same_site:None
361361- ~creation_time:now ~last_access:now ()
386386+ make ~domain ~path ~name ~value ~secure:(secure = "TRUE")
387387+ ~http_only:false ?expires ?same_site:None ~creation_time:now
388388+ ~last_access:now ()
362389 in
363390 add_cookie jar cookie;
364391 Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
···370397371398(** {1 File Operations} *)
372399373373-let load path =
400400+let load ~clock path =
374401 Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path);
375402376403 try
377404 let content = Eio.Path.load path in
378378- from_mozilla_format content
405405+ from_mozilla_format ~clock content
379406 with
380407 | Eio.Io _ ->
381408 Log.info (fun m -> m "Cookie file not found, creating empty jar");
+34-12
lib/cookeio.mli
···101101val create : unit -> jar
102102(** Create an empty cookie jar *)
103103104104-val load : Eio.Fs.dir_ty Eio.Path.t -> jar
105105-(** Load cookies from Mozilla format file *)
104104+val load : clock:_ Eio.Time.clock -> Eio.Fs.dir_ty Eio.Path.t -> jar
105105+(** Load cookies from Mozilla format file.
106106+107107+ Loads cookies from a file in Mozilla format, using the provided clock to set
108108+ creation and last access times. Returns an empty jar if the file doesn't
109109+ exist or cannot be loaded. *)
106110107111val save : Eio.Fs.dir_ty Eio.Path.t -> jar -> unit
108112(** Save cookies to Mozilla format file *)
···113117(** Add a cookie to the jar *)
114118115119val get_cookies :
116116- jar -> domain:string -> path:string -> is_secure:bool -> t list
117117-(** Get cookies applicable for a URL *)
120120+ jar ->
121121+ clock:_ Eio.Time.clock ->
122122+ domain:string ->
123123+ path:string ->
124124+ is_secure:bool ->
125125+ t list
126126+(** Get cookies applicable for a URL.
127127+128128+ Returns all cookies that match the given domain and path, and satisfy the
129129+ secure flag requirement. Also updates the last access time of matching
130130+ cookies using the provided clock. *)
118131119132val clear : jar -> unit
120133(** Clear all cookies *)
···136149137150(** {1 Cookie Creation and Parsing} *)
138151139139-val parse_set_cookie : domain:string -> path:string -> string -> t option
152152+val parse_set_cookie :
153153+ clock:_ Eio.Time.clock -> domain:string -> path:string -> string -> t option
140154(** Parse Set-Cookie header value into a cookie.
141155142156 Parses a Set-Cookie header value following RFC specifications:
143157 - Basic format: [NAME=VALUE; attribute1; attribute2=value2]
144144- - Supports all standard attributes: [expires], [domain], [path], [secure],
145145- [httponly], [samesite]
146146- - Returns [None] if parsing fails or cookie is invalid
158158+ - Supports all standard attributes: [expires], [max-age], [domain], [path],
159159+ [secure], [httponly], [samesite]
160160+ - Returns [None] if parsing fails or cookie validation fails
147161 - The [domain] and [path] parameters provide the request context for default
148162 values
163163+ - The [clock] parameter is used for calculating expiry times from [max-age]
164164+ attributes
165165+166166+ Cookie validation rules:
167167+ - [SameSite=None] requires the [Secure] flag to be set
149168150169 Example:
151151- [parse_set_cookie ~domain:"example.com" ~path:"/" "session=abc123; Secure;
152152- HttpOnly"] *)
170170+ [parse_set_cookie ~clock ~domain:"example.com" ~path:"/"
171171+ "session=abc123; Secure; HttpOnly"] *)
153172154173val make_cookie_header : t list -> string
155174(** Create cookie header value from cookies.
···177196val to_mozilla_format : jar -> string
178197(** Write cookies in Mozilla format *)
179198180180-val from_mozilla_format : string -> jar
181181-(** Parse Mozilla format cookies *)
199199+val from_mozilla_format : clock:_ Eio.Time.clock -> string -> jar
200200+(** Parse Mozilla format cookies.
201201+202202+ Creates a cookie jar from a string in Mozilla cookie format, using the
203203+ provided clock to set creation and last access times. *)
+26-18
test/test_cookeio.ml
···2121 && Option.equal Ptime.equal (Cookeio.expires c1) (Cookeio.expires c2)
2222 && Option.equal ( = ) (Cookeio.same_site c1) (Cookeio.same_site c2))
23232424-let test_load_mozilla_cookies () =
2424+let test_load_mozilla_cookies env =
2525+ let clock = Eio.Stdenv.clock env in
2526 let content =
2627 {|# Netscape HTTP Cookie File
2728# http://curl.haxx.se/rfc/cookie_spec.html
···3637#HttpOnly_.example.com TRUE /foo/ FALSE 1257894000 cookie-7 v$7
3738|}
3839 in
3939- let jar = from_mozilla_format content in
4040+ let jar = from_mozilla_format ~clock content in
4041 let cookies = get_all_cookies jar in
41424243 (* Check total number of cookies (should skip commented lines) *)
···114115115116let test_load_from_file env =
116117 (* This test loads from the actual test/cookies.txt file using the load function *)
118118+ let clock = Eio.Stdenv.clock env in
117119 let cwd = Eio.Stdenv.cwd env in
118120 let cookie_path = Eio.Path.(cwd / "cookies.txt") in
119119- let jar = load cookie_path in
121121+ let jar = load ~clock cookie_path in
120122 let cookies = get_all_cookies jar in
121123122124 (* Should have the same 5 cookies as the string test *)
···145147 Alcotest.(check (option (Alcotest.testable Ptime.pp Ptime.equal)))
146148 "file cookie-2 expires" None (Cookeio.expires cookie2)
147149148148-let test_cookie_matching () =
150150+let test_cookie_matching env =
151151+ let clock = Eio.Stdenv.clock env in
149152 let jar = create () in
150153151154 (* Add test cookies with different domain patterns *)
···171174172175 (* Test exact domain matching *)
173176 let cookies_http =
174174- get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false
177177+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
175178 in
176179 Alcotest.(check int) "http cookies count" 2 (List.length cookies_http);
177180178181 let cookies_https =
179179- get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:true
182182+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:true
180183 in
181184 Alcotest.(check int) "https cookies count" 3 (List.length cookies_https);
182185183186 (* Test subdomain matching *)
184187 let cookies_sub =
185185- get_cookies jar ~domain:"sub.example.com" ~path:"/" ~is_secure:false
188188+ get_cookies jar ~clock ~domain:"sub.example.com" ~path:"/" ~is_secure:false
186189 in
187190 Alcotest.(check int) "subdomain cookies count" 1 (List.length cookies_sub);
188191 let sub_cookie = List.hd cookies_sub in
189192 Alcotest.(check string) "subdomain cookie name" "subdomain" (Cookeio.name sub_cookie)
190193191191-let test_empty_jar () =
194194+let test_empty_jar env =
195195+ let clock = Eio.Stdenv.clock env in
192196 let jar = create () in
193197 Alcotest.(check bool) "empty jar" true (is_empty jar);
194198 Alcotest.(check int) "empty count" 0 (count jar);
···196200 "empty cookies" [] (get_all_cookies jar);
197201198202 let cookies =
199199- get_cookies jar ~domain:"example.com" ~path:"/" ~is_secure:false
203203+ get_cookies jar ~clock ~domain:"example.com" ~path:"/" ~is_secure:false
200204 in
201205 Alcotest.(check int) "no matching cookies" 0 (List.length cookies)
202206203203-let test_round_trip_mozilla_format () =
207207+let test_round_trip_mozilla_format env =
208208+ let clock = Eio.Stdenv.clock env in
204209 let jar = create () in
205210206211 let test_cookie =
···213218214219 (* Convert to Mozilla format and back *)
215220 let mozilla_format = to_mozilla_format jar in
216216- let jar2 = from_mozilla_format mozilla_format in
221221+ let jar2 = from_mozilla_format ~clock mozilla_format in
217222 let cookies2 = get_all_cookies jar2 in
218223219224 Alcotest.(check int) "round trip count" 1 (List.length cookies2);
···236241 [
237242 ( "mozilla_format",
238243 [
239239- test_case "Load Mozilla format from string" `Quick
240240- test_load_mozilla_cookies;
244244+ test_case "Load Mozilla format from string" `Quick (fun () ->
245245+ test_load_mozilla_cookies env);
241246 test_case "Load Mozilla format from file" `Quick (fun () ->
242247 test_load_from_file env);
243243- test_case "Round trip Mozilla format" `Quick
244244- test_round_trip_mozilla_format;
248248+ test_case "Round trip Mozilla format" `Quick (fun () ->
249249+ test_round_trip_mozilla_format env);
245250 ] );
246251 ( "cookie_matching",
247247- [ test_case "Domain and security matching" `Quick test_cookie_matching ]
252252+ [
253253+ test_case "Domain and security matching" `Quick (fun () ->
254254+ test_cookie_matching env);
255255+ ] );
256256+ ( "basic_operations",
257257+ [ test_case "Empty jar operations" `Quick (fun () -> test_empty_jar env) ]
248258 );
249249- ( "basic_operations",
250250- [ test_case "Empty jar operations" `Quick test_empty_jar ] );
251259 ]