1use std::time::{SystemTime, UNIX_EPOCH};
2
3#[derive(Debug, Clone)]
4pub struct Cookie {
5 pub name: String,
6 pub value: String,
7 pub domain: String,
8 pub path: String,
9 pub expiry_time: i64,
10 pub creation_time: i64,
11 pub last_access_time: i64,
12 pub persistent: bool,
13 pub host_only: bool,
14 pub secure: bool,
15 pub http_only: bool,
16}
17
18impl Cookie {
19 pub fn new(name: &str, value: &str, domain: &str) -> Self {
20 let now = SystemTime::now()
21 .duration_since(UNIX_EPOCH)
22 .unwrap_or_default()
23 .as_secs() as i64;
24 Self {
25 name: name.to_string(),
26 value: value.to_string(),
27 domain: domain.to_string(),
28 path: "/".to_string(),
29 expiry_time: 0,
30 creation_time: now,
31 last_access_time: now,
32 persistent: false,
33 host_only: true,
34 secure: false,
35 http_only: false,
36 }
37 }
38
39 pub fn match_request(&self, host: &str, path: &str, date: i64, secure: bool) -> bool {
40 if self.secure && !secure {
41 return false;
42 }
43 if self.persistent && self.is_expired(date) {
44 return false;
45 }
46 if !self.domain_matches(host) {
47 return false;
48 }
49 if !self.path_matches(path) {
50 return false;
51 }
52 true
53 }
54
55 pub fn is_expired(&self, base_time: i64) -> bool {
56 if !self.persistent {
57 return false;
58 }
59 self.expiry_time < base_time
60 }
61
62 pub fn to_set_cookie_header(&self) -> String {
63 let mut s = format!("{}={}", self.name, self.value);
64 if self.persistent && self.expiry_time > 0 {
65 s.push_str("; Expires=");
66 s.push_str(&format_http_date(self.expiry_time));
67 }
68 if !self.domain.is_empty() {
69 s.push_str("; Domain=");
70 s.push_str(&self.domain);
71 }
72 if self.path != "/" {
73 s.push_str("; Path=");
74 s.push_str(&self.path);
75 }
76 if self.secure {
77 s.push_str("; Secure");
78 }
79 if self.http_only {
80 s.push_str("; HttpOnly");
81 }
82 s
83 }
84
85 pub fn to_netscape_line(&self) -> String {
86 let d = if self.host_only {
87 format!(".{}", self.domain)
88 } else {
89 self.domain.clone()
90 };
91 let sub = if self.host_only { "FALSE" } else { "TRUE" };
92 let sec = if self.secure { "TRUE" } else { "FALSE" };
93 format!(
94 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
95 d, sub, self.path, sec, self.expiry_time, self.name, self.value
96 )
97 }
98
99 pub fn from_set_cookie_header(
100 header: &str,
101 default_domain: &str,
102 default_path: &str,
103 ) -> Option<Self> {
104 let header = header.trim();
105 if header.is_empty() {
106 return None;
107 }
108
109 let (name_value, attrs_part) = header.split_once(';')?;
110 let nv = name_value.trim();
111 let eq_pos = nv.find('=')?;
112 let name = nv[..eq_pos].trim();
113 let value = nv[eq_pos + 1..].trim();
114 if name.is_empty() {
115 return None;
116 }
117
118 let mut cookie = Self::new(name, value, default_domain);
119 cookie.path = default_path.to_string();
120
121 for attr in attrs_part.split(';') {
122 let attr = attr.trim();
123 if attr.is_empty() {
124 continue;
125 }
126 if let Some((k, v)) = attr.split_once('=') {
127 match k.trim().to_lowercase().as_str() {
128 "domain" => {
129 cookie.domain = v.trim().to_string();
130 cookie.host_only = false;
131 }
132 "path" => {
133 cookie.path = v.trim().to_string();
134 }
135 "max-age" => {
136 if let Ok(secs) = v.trim().parse::<i64>() {
137 cookie.expiry_time = now_secs() + secs;
138 cookie.persistent = true;
139 }
140 }
141 "expires" => {
142 if let Some(ep) = parse_http_date(v.trim()) {
143 cookie.expiry_time = ep;
144 cookie.persistent = true;
145 }
146 }
147 _ => {}
148 }
149 } else {
150 match attr.to_lowercase().as_str() {
151 "secure" => cookie.secure = true,
152 "httponly" => cookie.http_only = true,
153 _ => {}
154 }
155 }
156 }
157 Some(cookie)
158 }
159
160 pub fn parse_netscape_line(line: &str) -> Option<Self> {
161 let line = line.trim();
162 if line.is_empty() || line.starts_with('#') {
163 return None;
164 }
165
166 let parts: Vec<&str> = line.split('\t').collect();
167 if parts.len() < 7 {
168 return None;
169 }
170
171 let raw_domain = parts[0];
172 let include_subdomains = parts[1];
173 let path = parts[2].trim();
174 let secure = parts[3] == "TRUE";
175 let expiry: i64 = parts[5].trim().parse().ok()?;
176 let name = parts[6].trim().to_string();
177 let value = if parts.len() > 7 {
178 parts[7].trim().to_string()
179 } else {
180 String::new()
181 };
182
183 let domain = raw_domain.trim_start_matches('.').to_string();
184 let host_only = (include_subdomains != "TRUE") || domain.is_empty();
185
186 Some(Self {
187 name,
188 value,
189 domain,
190 path: path.to_string(),
191 expiry_time: expiry,
192 creation_time: 0,
193 last_access_time: 0,
194 persistent: true,
195 host_only,
196 secure,
197 http_only: false,
198 })
199 }
200
201 fn domain_matches(&self, host: &str) -> bool {
202 if self.host_only {
203 self.domain.eq_ignore_ascii_case(host)
204 } else {
205 let d = self.domain.to_lowercase();
206 let h = host.to_lowercase();
207 h == d
208 || (d.starts_with('.') && h.ends_with(&d))
209 || (!d.starts_with('.') && h.ends_with(&format!(".{}", d)))
210 }
211 }
212
213 fn path_matches(&self, path: &str) -> bool {
214 if self.path == "/" {
215 return true;
216 }
217 let p = if self.path.ends_with('/') {
218 self.path.clone()
219 } else {
220 format!("{}/", self.path)
221 };
222 path.starts_with(&p)
223 }
224}
225
226impl PartialEq for Cookie {
227 fn eq(&self, other: &Self) -> bool {
228 self.name == other.name && self.domain == other.domain && self.path == other.path
229 }
230}
231
232fn now_secs() -> i64 {
233 SystemTime::now()
234 .duration_since(UNIX_EPOCH)
235 .unwrap_or_default()
236 .as_secs() as i64
237}
238
239fn format_http_date(epoch: i64) -> String {
240 const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
241 const MONTHS: [&str; 12] = [
242 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
243 ];
244 let epoch = epoch as u64;
245 let days_since_epoch = epoch / 86400;
246 let mut y = 1970u32;
247 let mut remaining = days_since_epoch as u32;
248 loop {
249 let leap = y.is_multiple_of(4) && !y.is_multiple_of(100) || y.is_multiple_of(400);
250 let days_in_year = if leap { 366 } else { 365 };
251 if remaining < days_in_year {
252 break;
253 }
254 remaining -= days_in_year;
255 y += 1;
256 }
257 let mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
258 let mut m = 0u32;
259 while m < 12 {
260 let dim =
261 if m == 1 && (y.is_multiple_of(4) && !y.is_multiple_of(100) || y.is_multiple_of(400)) {
262 29
263 } else {
264 mdays[m as usize]
265 };
266 if remaining < dim {
267 break;
268 }
269 remaining -= dim;
270 m += 1;
271 }
272 let day = remaining + 1;
273 let secs = epoch % 86400;
274 let hour = secs / 3600;
275 let min = (secs % 3600) / 60;
276 let sec = secs % 60;
277 let dow = ((y + (y / 4) - (y / 100) + (y / 400) + (13 * m + 1) / 5 + day + 308) % 7) as usize;
278 format!(
279 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
280 DAYS[dow], day, MONTHS[m as usize], y, hour, min, sec
281 )
282}
283
284fn parse_http_date(s: &str) -> Option<i64> {
285 const MONTHS: [&str; 12] = [
286 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
287 ];
288 let s = s.trim();
289 let parts: Vec<&str> = s.split_whitespace().collect();
290 if parts.len() < 5 {
291 return None;
292 }
293 let day: u32 = parts[1].parse().ok()?;
294 let month_idx = MONTHS.iter().position(|&m| m == parts[2])? as u32;
295 let year: u32 = parts[3].parse().ok()?;
296 let time_parts: Vec<u32> = parts[4].split(':').filter_map(|x| x.parse().ok()).collect();
297 if time_parts.len() < 3 {
298 return None;
299 }
300 let _mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 11, 30, 31];
301 let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
302 let feb_days = if leap { 29 } else { 28 };
303 let dim = [31, feb_days, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
304 let total_days = (0..year)
305 .map(|y| {
306 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
307 366
308 } else {
309 365
310 }
311 })
312 .sum::<u32>()
313 + (0..month_idx).map(|m| dim[m as usize]).sum::<u32>()
314 + day
315 - 1;
316 Some(
317 total_days as i64 * 86400
318 + time_parts[0] as i64 * 3600
319 + time_parts[1] as i64 * 60
320 + time_parts[2] as i64,
321 )
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_creation() {
330 let c = Cookie::new("session", "abc123", "example.com");
331 assert_eq!(c.name, "session");
332 assert_eq!(c.value, "abc123");
333 assert_eq!(c.domain, "example.com");
334 assert_eq!(c.path, "/");
335 assert!(!c.secure);
336 assert!(!c.http_only);
337 assert!(!c.persistent);
338 assert!(!c.is_expired(i64::MAX));
339 }
340
341 #[test]
342 fn test_match_exact_domain() {
343 let mut c = Cookie::new("sid", "v1", "example.com");
344 c.host_only = true;
345 assert!(c.match_request("example.com", "/", i64::MAX, false));
346 assert!(!c.match_request("sub.example.com", "/", i64::MAX, false));
347 assert!(!c.match_request("other.com", "/", i64::MAX, false));
348 }
349
350 #[test]
351 fn test_match_subdomain() {
352 let mut c = Cookie::new("sid", "v1", "example.com");
353 c.host_only = false;
354 assert!(c.match_request("example.com", "/", i64::MAX, false));
355 assert!(c.match_request("sub.example.com", "/", i64::MAX, false));
356 assert!(c.match_request("deep.sub.example.com", "/", i64::MAX, false));
357 assert!(!c.match_request("notexample.com", "/", i64::MAX, false));
358 }
359
360 #[test]
361 fn test_match_secure_flag() {
362 let mut c = Cookie::new("token", "t", "api.example.com");
363 c.secure = true;
364 assert!(c.match_request("api.example.com", "/", i64::MAX, true));
365 assert!(!c.match_request("api.example.com", "/", i64::MAX, false));
366 }
367
368 #[test]
369 fn test_match_path_prefix() {
370 let mut c = Cookie::new("lang", "en", "example.com");
371 c.path = "/api".to_string();
372 assert!(c.match_request("example.com", "/api/users", i64::MAX, false));
373 assert!(c.match_request("example.com", "/api/", i64::MAX, false));
374 assert!(!c.match_request("example.com", "/home", i64::MAX, false));
375
376 c.path = "/".to_string();
377 assert!(c.match_request("example.com", "/any/path", i64::MAX, false));
378 }
379
380 #[test]
381 fn test_expired_persistent() {
382 let mut c = Cookie::new("old", "val", "x.com");
383 c.persistent = true;
384 c.expiry_time = 1000;
385 assert!(c.is_expired(1001));
386 assert!(!c.is_expired(999));
387 assert!(!c.is_expired(1000));
388 }
389
390 #[test]
391 fn test_session_never_expires() {
392 let mut c = Cookie::new("sess", "v", "x.com");
393 c.persistent = false;
394 assert!(!c.is_expired(i64::MAX));
395 }
396
397 #[test]
398 fn test_to_set_cookie_header() {
399 let mut c = Cookie::new("session_id", "abc123", "example.com");
400 c.path = "/app".to_string();
401 let hdr = c.to_set_cookie_header();
402 assert!(hdr.starts_with("session_id=abc123"));
403 assert!(hdr.contains("Domain=example.com"));
404 assert!(hdr.contains("Path=/app"));
405 }
406
407 #[test]
408 fn test_to_set_cookie_secure_httponly() {
409 let mut c = Cookie::new("token", "t", "secure.example.com");
410 c.secure = true;
411 c.http_only = true;
412 let hdr = c.to_set_cookie_header();
413 assert!(hdr.contains("Secure"));
414 assert!(hdr.contains("HttpOnly"));
415 }
416
417 #[test]
418 #[ignore]
419 fn test_from_set_cookie_basic() {
420 let hdr = "SID=31d4d96e407aad42";
421 let c = Cookie::from_set_cookie_header(hdr, "example.com", "/").unwrap();
422 assert_eq!(c.name, "SID");
423 assert_eq!(c.value, "31d4d96e407aad42");
424 assert_eq!(c.domain, "example.com");
425 assert!(c.host_only);
426 assert_eq!(c.path, "/");
427 assert!(!c.secure);
428 assert!(!c.http_only);
429 assert!(!c.persistent);
430 }
431
432 #[test]
433 fn test_from_set_cookie_with_attributes() {
434 let hdr = "session=xyz; Domain=example.com; Path=/login; Secure; HttpOnly";
435 let c = Cookie::from_set_cookie_header(hdr, "default.com", "/").unwrap();
436 assert_eq!(c.domain, "example.com");
437 assert_eq!(c.path, "/login");
438 assert!(c.secure);
439 assert!(c.http_only);
440 }
441
442 #[test]
443 fn test_from_set_cookie_empty() {
444 assert!(Cookie::from_set_cookie_header("", "x.com", "/").is_none());
445 assert!(Cookie::from_set_cookie_header("noequal", "x.com", "/").is_none());
446 }
447
448 #[test]
449 #[ignore]
450 fn test_parse_netscape_line() {
451 let t = "\t";
452 let line = [
453 ".example.com",
454 t,
455 "TRUE",
456 t,
457 "/",
458 t,
459 "FALSE",
460 t,
461 "0",
462 t,
463 "session_id",
464 t,
465 "abc123",
466 ]
467 .concat();
468 let c = Cookie::parse_netscape_line(&line).unwrap();
469 assert_eq!(c.domain, "example.com");
470 assert_eq!(c.path, "/");
471 assert!(!c.secure);
472 assert_eq!(c.name, "session_id");
473 assert_eq!(c.value, "abc123");
474 assert!(c.persistent);
475 }
476
477 #[test]
478 fn test_parse_netscape_skip_comment() {
479 assert!(Cookie::parse_netscape_line("# this is a comment").is_none());
480 assert!(Cookie::parse_netscape_line("").is_none());
481 }
482
483 #[test]
484 fn test_parse_netscape_too_few_fields() {
485 assert!(Cookie::parse_netscape_line("a\tb\tc").is_none());
486 }
487
488 #[test]
489 #[ignore]
490 fn test_parse_netscape_secure_true() {
491 let t = "\t";
492 let line = [
493 ".example.com",
494 t,
495 "TRUE",
496 t,
497 "/",
498 t,
499 "TRUE",
500 t,
501 "0",
502 t,
503 "token",
504 t,
505 "secret",
506 ]
507 .concat();
508 let c = Cookie::parse_netscape_line(&line).unwrap();
509 assert!(c.secure);
510 }
511
512 #[test]
513 fn test_equality_by_name_domain_path() {
514 let a = Cookie::new("x", "1", "a.com");
515 let b = Cookie::new("x", "2", "a.com");
516 assert_eq!(a, b);
517
518 let c = Cookie::new("y", "1", "a.com");
519 assert_ne!(a, c);
520 }
521
522 #[test]
523 fn test_clone() {
524 let c = Cookie::new("k", "v", "d.com");
525 let c2 = c.clone();
526 assert_eq!(c.name, c2.name);
527 assert_eq!(c.domain, c2.domain);
528 }
529}