My agentic slop goes here. Not intended for anyone else!
1let src = Logs.Src.create "cookeio" ~doc:"Cookie management" 2 3module Log = (val Logs.src_log src : Logs.LOG) 4 5type same_site = [ `Strict | `Lax | `None ] 6(** Cookie same-site policy *) 7 8type t = { 9 domain : string; 10 path : string; 11 name : string; 12 value : string; 13 secure : bool; 14 http_only : bool; 15 expires : Ptime.t option; 16 same_site : same_site option; 17 creation_time : Ptime.t; 18 last_access : Ptime.t; 19} 20(** HTTP Cookie *) 21 22type jar = { mutable cookies : t list; mutex : Eio.Mutex.t } 23(** Cookie jar for storing and managing cookies *) 24 25(** {1 Cookie Accessors} *) 26 27let domain cookie = cookie.domain 28let path cookie = cookie.path 29let name cookie = cookie.name 30let value cookie = cookie.value 31let secure cookie = cookie.secure 32let http_only cookie = cookie.http_only 33let expires cookie = cookie.expires 34let same_site cookie = cookie.same_site 35let creation_time cookie = cookie.creation_time 36let last_access cookie = cookie.last_access 37 38let make ~domain ~path ~name ~value ?(secure = false) ?(http_only = false) 39 ?expires ?same_site ~creation_time ~last_access () = 40 { domain; path; name; value; secure; http_only; expires; same_site; creation_time; last_access } 41 42(** {1 Cookie Jar Creation} *) 43 44let create () = 45 Log.debug (fun m -> m "Creating new empty cookie jar"); 46 { cookies = []; mutex = Eio.Mutex.create () } 47 48(** {1 Cookie Matching Helpers} *) 49 50let domain_matches cookie_domain request_domain = 51 (* Cookie domain .example.com matches example.com and sub.example.com *) 52 if String.starts_with ~prefix:"." cookie_domain then 53 let domain_suffix = String.sub cookie_domain 1 (String.length cookie_domain - 1) in 54 request_domain = domain_suffix 55 || String.ends_with ~suffix:("." ^ domain_suffix) request_domain 56 else cookie_domain = request_domain 57 58let path_matches cookie_path request_path = 59 (* Cookie path /foo matches /foo, /foo/, /foo/bar *) 60 String.starts_with ~prefix:cookie_path request_path 61 62let is_expired cookie clock = 63 match cookie.expires with 64 | None -> false (* Session cookie *) 65 | Some exp_time -> 66 let now = 67 Ptime.of_float_s (Eio.Time.now clock) 68 |> Option.value ~default:Ptime.epoch 69 in 70 Ptime.compare now exp_time > 0 71 72(** {1 Cookie Parsing} *) 73 74let parse_cookie_attribute attr attr_value cookie = 75 let attr_lower = String.lowercase_ascii attr in 76 match attr_lower with 77 | "domain" -> make ~domain:attr_value ~path:(path cookie) ~name:(name cookie) 78 ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 79 ?expires:(expires cookie) ?same_site:(same_site cookie) 80 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 81 | "path" -> make ~domain:(domain cookie) ~path:attr_value ~name:(name cookie) 82 ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 83 ?expires:(expires cookie) ?same_site:(same_site cookie) 84 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 85 | "expires" -> ( 86 (* Parse various date formats *) 87 try 88 let time, _tz_offset, _tz_string = 89 Ptime.of_rfc3339 attr_value |> Result.get_ok 90 in 91 make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 92 ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 93 ~expires:time ?same_site:(same_site cookie) 94 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 95 with _ -> 96 Log.debug (fun m -> m "Failed to parse expires: %s" attr_value); 97 cookie) 98 | "max-age" -> ( 99 try 100 let seconds = int_of_string attr_value in 101 let now = Unix.time () in 102 let expires = Ptime.of_float_s (now +. float_of_int seconds) in 103 make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 104 ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 105 ?expires ?same_site:(same_site cookie) 106 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 107 with _ -> cookie) 108 | "secure" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 109 ~value:(value cookie) ~secure:true ~http_only:(http_only cookie) 110 ?expires:(expires cookie) ?same_site:(same_site cookie) 111 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 112 | "httponly" -> make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 113 ~value:(value cookie) ~secure:(secure cookie) ~http_only:true 114 ?expires:(expires cookie) ?same_site:(same_site cookie) 115 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 116 | "samesite" -> 117 let same_site_val = 118 match String.lowercase_ascii attr_value with 119 | "strict" -> Some `Strict 120 | "lax" -> Some `Lax 121 | "none" -> Some `None 122 | _ -> None 123 in 124 make ~domain:(domain cookie) ~path:(path cookie) ~name:(name cookie) 125 ~value:(value cookie) ~secure:(secure cookie) ~http_only:(http_only cookie) 126 ?expires:(expires cookie) ?same_site:same_site_val 127 ~creation_time:(creation_time cookie) ~last_access:(last_access cookie) () 128 | _ -> cookie 129 130let rec parse_set_cookie ~domain:request_domain ~path:request_path header_value = 131 Log.debug (fun m -> m "Parsing Set-Cookie: %s" header_value); 132 133 (* Split into attributes *) 134 let parts = String.split_on_char ';' header_value |> List.map String.trim in 135 136 match parts with 137 | [] -> None 138 | name_value :: attrs -> ( 139 (* Parse name=value *) 140 match String.index_opt name_value '=' with 141 | None -> None 142 | Some eq_pos -> 143 let name = String.sub name_value 0 eq_pos |> String.trim in 144 let cookie_value = 145 String.sub name_value (eq_pos + 1) 146 (String.length name_value - eq_pos - 1) 147 |> String.trim 148 in 149 150 let now = 151 Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch 152 in 153 let base_cookie = 154 make ~domain:request_domain ~path:request_path ~name ~value:cookie_value ~secure:false ~http_only:false 155 ?expires:None ?same_site:None ~creation_time:now ~last_access:now () 156 in 157 158 (* Parse attributes *) 159 let cookie = 160 List.fold_left 161 (fun cookie attr -> 162 match String.index_opt attr '=' with 163 | None -> parse_cookie_attribute attr "" cookie 164 | Some eq -> 165 let attr_name = String.sub attr 0 eq |> String.trim in 166 let attr_value = 167 String.sub attr (eq + 1) (String.length attr - eq - 1) 168 |> String.trim 169 in 170 parse_cookie_attribute attr_name attr_value cookie) 171 base_cookie attrs 172 in 173 174 Log.debug (fun m -> m "Parsed cookie: %a" pp cookie); 175 Some cookie) 176 177and make_cookie_header cookies = 178 cookies 179 |> List.map (fun c -> Printf.sprintf "%s=%s" (name c) (value c)) 180 |> String.concat "; " 181 182(** {1 Pretty Printing} *) 183 184and pp_same_site ppf = function 185 | `Strict -> Format.pp_print_string ppf "Strict" 186 | `Lax -> Format.pp_print_string ppf "Lax" 187 | `None -> Format.pp_print_string ppf "None" 188 189and pp ppf cookie = 190 Format.fprintf ppf 191 "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ secure=%b;@ \ 192 http_only=%b;@ expires=%a;@ same_site=%a }@]" 193 (name cookie) (value cookie) (domain cookie) (path cookie) (secure cookie) 194 (http_only cookie) 195 (Format.pp_print_option Ptime.pp) 196 (expires cookie) 197 (Format.pp_print_option pp_same_site) 198 (same_site cookie) 199 200let pp_jar ppf jar = 201 Eio.Mutex.lock jar.mutex; 202 let cookies = jar.cookies in 203 Eio.Mutex.unlock jar.mutex; 204 205 Format.fprintf ppf "@[<v>CookieJar with %d cookies:@," (List.length cookies); 206 List.iter (fun cookie -> Format.fprintf ppf " %a@," pp cookie) cookies; 207 Format.fprintf ppf "@]" 208 209(** {1 Cookie Management} *) 210 211let add_cookie jar cookie = 212 Log.debug (fun m -> 213 m "Adding cookie: %s=%s for domain %s" (name cookie) (value cookie) 214 (domain cookie)); 215 216 Eio.Mutex.lock jar.mutex; 217 (* Remove existing cookie with same name, domain, and path *) 218 jar.cookies <- 219 List.filter 220 (fun c -> 221 not 222 (name c = name cookie && domain c = domain cookie 223 && path c = path cookie)) 224 jar.cookies; 225 jar.cookies <- cookie :: jar.cookies; 226 Eio.Mutex.unlock jar.mutex 227 228let get_cookies jar ~domain:request_domain ~path:request_path ~is_secure = 229 Log.debug (fun m -> 230 m "Getting cookies for domain=%s path=%s secure=%b" request_domain request_path is_secure); 231 232 Eio.Mutex.lock jar.mutex; 233 let applicable = 234 List.filter 235 (fun cookie -> 236 domain_matches (domain cookie) request_domain 237 && path_matches (path cookie) request_path 238 && ((not (secure cookie)) || is_secure)) 239 jar.cookies 240 in 241 242 (* Update last access time *) 243 let now = 244 Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch 245 in 246 let updated = 247 List.map 248 (fun c -> 249 if List.memq c applicable then 250 make ~domain:(domain c) ~path:(path c) ~name:(name c) ~value:(value c) 251 ~secure:(secure c) ~http_only:(http_only c) ?expires:(expires c) 252 ?same_site:(same_site c) ~creation_time:(creation_time c) ~last_access:now () 253 else c) 254 jar.cookies 255 in 256 jar.cookies <- updated; 257 Eio.Mutex.unlock jar.mutex; 258 259 Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable)); 260 applicable 261 262let clear jar = 263 Log.info (fun m -> m "Clearing all cookies"); 264 Eio.Mutex.lock jar.mutex; 265 jar.cookies <- []; 266 Eio.Mutex.unlock jar.mutex 267 268let clear_expired jar ~clock = 269 Eio.Mutex.lock jar.mutex; 270 let before_count = List.length jar.cookies in 271 jar.cookies <- List.filter (fun c -> not (is_expired c clock)) jar.cookies; 272 let removed = before_count - List.length jar.cookies in 273 Eio.Mutex.unlock jar.mutex; 274 Log.info (fun m -> m "Cleared %d expired cookies" removed) 275 276let clear_session_cookies jar = 277 Eio.Mutex.lock jar.mutex; 278 let before_count = List.length jar.cookies in 279 jar.cookies <- List.filter (fun c -> expires c <> None) jar.cookies; 280 let removed = before_count - List.length jar.cookies in 281 Eio.Mutex.unlock jar.mutex; 282 Log.info (fun m -> m "Cleared %d session cookies" removed) 283 284let count jar = 285 Eio.Mutex.lock jar.mutex; 286 let n = List.length jar.cookies in 287 Eio.Mutex.unlock jar.mutex; 288 n 289 290let get_all_cookies jar = 291 Eio.Mutex.lock jar.mutex; 292 let cookies = jar.cookies in 293 Eio.Mutex.unlock jar.mutex; 294 cookies 295 296let is_empty jar = 297 Eio.Mutex.lock jar.mutex; 298 let empty = jar.cookies = [] in 299 Eio.Mutex.unlock jar.mutex; 300 empty 301 302(** {1 Mozilla Format} *) 303 304let to_mozilla_format_internal jar = 305 let buffer = Buffer.create 1024 in 306 Buffer.add_string buffer "# Netscape HTTP Cookie File\n"; 307 Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n"; 308 309 List.iter 310 (fun cookie -> 311 let include_subdomains = 312 if String.starts_with ~prefix:"." (domain cookie) then "TRUE" else "FALSE" 313 in 314 let secure_flag = if secure cookie then "TRUE" else "FALSE" in 315 let expires_str = 316 match expires cookie with 317 | None -> "0" (* Session cookie *) 318 | Some t -> 319 let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in 320 epoch 321 in 322 323 Buffer.add_string buffer 324 (Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" (domain cookie) 325 include_subdomains (path cookie) secure_flag expires_str (name cookie) 326 (value cookie))) 327 jar.cookies; 328 329 Buffer.contents buffer 330 331let to_mozilla_format jar = 332 Eio.Mutex.lock jar.mutex; 333 let result = to_mozilla_format_internal jar in 334 Eio.Mutex.unlock jar.mutex; 335 result 336 337let from_mozilla_format content = 338 Log.debug (fun m -> m "Parsing Mozilla format cookies"); 339 let jar = create () in 340 341 let lines = String.split_on_char '\n' content in 342 List.iter 343 (fun line -> 344 let line = String.trim line in 345 if line <> "" && not (String.starts_with ~prefix:"#" line) then 346 match String.split_on_char '\t' line with 347 | [ domain; _include_subdomains; path; secure; expires; name; value ] -> 348 let now = 349 Ptime.of_float_s (Unix.time ()) 350 |> Option.value ~default:Ptime.epoch 351 in 352 let expires = 353 let exp_int = try int_of_string expires with _ -> 0 in 354 if exp_int = 0 then None 355 else Ptime.of_float_s (float_of_int exp_int) 356 in 357 358 let cookie = 359 make ~domain ~path ~name ~value 360 ~secure:(secure = "TRUE") ~http_only:false 361 ?expires ?same_site:None 362 ~creation_time:now ~last_access:now () 363 in 364 add_cookie jar cookie; 365 Log.debug (fun m -> m "Loaded cookie: %s=%s" name value) 366 | _ -> Log.warn (fun m -> m "Invalid cookie line: %s" line)) 367 lines; 368 369 Log.info (fun m -> m "Loaded %d cookies" (List.length jar.cookies)); 370 jar 371 372(** {1 File Operations} *) 373 374let load path = 375 Log.info (fun m -> m "Loading cookies from %a" Eio.Path.pp path); 376 377 try 378 let content = Eio.Path.load path in 379 from_mozilla_format content 380 with 381 | Eio.Io _ -> 382 Log.info (fun m -> m "Cookie file not found, creating empty jar"); 383 create () 384 | exn -> 385 Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn)); 386 create () 387 388let save path jar = 389 Log.info (fun m -> 390 m "Saving %d cookies to %a" (List.length jar.cookies) Eio.Path.pp path); 391 392 let content = to_mozilla_format jar in 393 394 try 395 Eio.Path.save ~create:(`Or_truncate 0o600) path content; 396 Log.debug (fun m -> m "Cookies saved successfully") 397 with exn -> 398 Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))