1use std::fmt;
7use std::time::Duration;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Cookie {
12 pub name: String,
13 pub value: String,
14 pub domain: Option<String>,
15 pub path: Option<String>,
16 pub max_age: Option<Duration>,
18 pub secure: bool,
19 pub http_only: bool,
20 pub same_site: Option<SameSite>,
21 pub expires_at: Option<std::time::Instant>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SameSite {
28 Strict,
30 Lax,
32 None,
34}
35
36impl Cookie {
37 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
39 Self {
40 name: name.into(),
41 value: value.into(),
42 domain: None,
43 path: None,
44 max_age: None,
45 secure: false,
46 http_only: false,
47 same_site: None,
48 expires_at: None,
49 }
50 }
51
52 pub fn name(&self) -> &str {
54 &self.name
55 }
56
57 pub fn value(&self) -> &str {
59 &self.value
60 }
61
62 pub fn domain(&self) -> Option<&str> {
64 self.domain.as_deref()
65 }
66
67 pub fn path(&self) -> Option<&str> {
69 self.path.as_deref()
70 }
71
72 pub fn max_age(&self) -> Option<Duration> {
74 self.max_age
75 }
76
77 pub fn is_secure(&self) -> bool {
79 self.secure
80 }
81
82 pub fn is_http_only(&self) -> bool {
84 self.http_only
85 }
86
87 pub fn same_site(&self) -> Option<SameSite> {
89 self.same_site
90 }
91
92 pub fn set_domain(mut self, domain: impl Into<String>) -> Self {
94 self.domain = Some(domain.into());
95 self
96 }
97
98 pub fn set_path(mut self, path: impl Into<String>) -> Self {
100 self.path = Some(path.into());
101 self
102 }
103
104 pub fn set_max_age(mut self, seconds: u64) -> Self {
106 self.max_age = Some(Duration::from_secs(seconds));
107 self
108 }
109
110 pub fn set_secure(mut self, secure: bool) -> Self {
112 self.secure = secure;
113 self
114 }
115
116 pub fn set_http_only(mut self, http_only: bool) -> Self {
118 self.http_only = http_only;
119 self
120 }
121
122 pub fn set_same_site(mut self, same_site: SameSite) -> Self {
124 self.same_site = Some(same_site);
125 self
126 }
127
128 pub fn parse_set_cookie(header: &str) -> Option<Self> {
131 let mut parts = header.split(';');
132 let first = parts.next()?.trim();
133 let (name, value) = first.split_once('=')?;
134 let name = name.trim();
135 let value = value.trim();
136
137 if name.is_empty() {
138 return None;
139 }
140
141 let mut cookie = Cookie::new(name, value);
142
143 for attr in parts {
144 let attr = attr.trim();
145 if attr.is_empty() {
146 continue;
147 }
148 if let Some((key, val)) = attr.split_once('=') {
149 let key = key.trim().to_lowercase();
150 let val = val.trim();
151 match key.as_str() {
152 "domain" => cookie.domain = Some(val.to_string()),
153 "path" => cookie.path = Some(val.to_string()),
154 "max-age" => {
155 cookie.max_age = val.parse::<u64>().ok().map(Duration::from_secs);
156 }
157 "samesite" => {
158 cookie.same_site = match val.to_lowercase().as_str() {
159 "strict" => Some(SameSite::Strict),
160 "lax" => Some(SameSite::Lax),
161 "none" => Some(SameSite::None),
162 _ => None,
163 };
164 }
165 _ => {}
166 }
167 } else {
168 match attr.to_lowercase().as_str() {
169 "secure" => cookie.secure = true,
170 "httponly" => cookie.http_only = true,
171 _ => {}
172 }
173 }
174 }
175
176 Some(cookie)
177 }
178
179 pub fn to_cookie_header(&self) -> String {
181 format!("{}={}", self.name, self.value)
182 }
183}
184
185impl fmt::Display for Cookie {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 write!(f, "{}={}", self.name, self.value)?;
188 if let Some(ref domain) = self.domain {
189 write!(f, "; Domain={domain}")?;
190 }
191 if let Some(ref path) = self.path {
192 write!(f, "; Path={path}")?;
193 }
194 if let Some(max_age) = self.max_age {
195 write!(f, "; Max-Age={}", max_age.as_secs())?;
196 }
197 if self.secure {
198 write!(f, "; Secure")?;
199 }
200 if self.http_only {
201 write!(f, "; HttpOnly")?;
202 }
203 if let Some(same_site) = self.same_site {
204 match same_site {
205 SameSite::Strict => write!(f, "; SameSite=Strict")?,
206 SameSite::Lax => write!(f, "; SameSite=Lax")?,
207 SameSite::None => write!(f, "; SameSite=None")?,
208 }
209 }
210 Ok(())
211 }
212}
213
214fn domain_match(cookie_domain: &str, request_host: &str) -> bool {
220 let cd = cookie_domain.to_lowercase();
221 let rh_full = request_host.to_lowercase();
222 let rh = rh_full.split(':').next().unwrap_or(&rh_full);
224 let cd = cd.strip_prefix('.').unwrap_or(&cd);
226 if rh == cd {
228 return true;
229 }
230 if rh.ends_with(&format!(".{cd}")) {
232 return cd.parse::<std::net::IpAddr>().is_err();
234 }
235 false
236}
237
238fn path_match(cookie_path: &str, request_path: &str) -> bool {
240 if request_path == cookie_path {
241 return true;
242 }
243 if let Some(remaining) = request_path.strip_prefix(cookie_path) {
244 return remaining.starts_with('/') || cookie_path.ends_with('/');
246 }
247 false
248}
249
250#[derive(Debug, Clone, Default)]
256pub struct CookieJar {
257 pub cookies: Vec<Cookie>,
258}
259
260impl CookieJar {
261 pub fn new() -> Self {
263 Self::default()
264 }
265
266 pub fn insert(&mut self, cookie: Cookie) {
268 self.cookies.retain(|c| c.name != cookie.name);
269 self.cookies.push(cookie);
270 }
271
272 pub fn get(&self, name: &str) -> Option<&Cookie> {
274 self.cookies.iter().find(|c| c.name == name)
275 }
276
277 pub fn remove(&mut self, name: &str) -> Option<Cookie> {
279 if let Some(pos) = self.cookies.iter().position(|c| c.name == name) {
280 Some(self.cookies.remove(pos))
281 } else {
282 None
283 }
284 }
285
286 pub fn iter(&self) -> impl Iterator<Item = &Cookie> {
288 self.cookies.iter()
289 }
290
291 pub fn len(&self) -> usize {
293 self.cookies.len()
294 }
295
296 pub fn is_empty(&self) -> bool {
298 self.cookies.is_empty()
299 }
300
301 pub fn to_cookie_header(&self) -> String {
303 self.cookies
304 .iter()
305 .map(|c| c.to_cookie_header())
306 .collect::<Vec<_>>()
307 .join("; ")
308 }
309
310 pub fn add_from_set_cookie_headers<'a, I: IntoIterator<Item = &'a str>>(&mut self, headers: I) {
312 for header in headers {
313 if let Some(cookie) = Cookie::parse_set_cookie(header) {
314 self.insert(cookie);
315 }
316 }
317 }
318
319 pub fn insert_for_url(&mut self, mut cookie: Cookie, request_url: &http::Uri) {
321 if cookie.domain.is_none() {
323 if let Some(host) = request_url.host() {
324 cookie.domain = Some(host.split(':').next().unwrap_or(host).to_lowercase());
325 }
326 } else {
327 if let Some(d) = &cookie.domain {
329 cookie.domain = Some(d.trim_start_matches('.').to_lowercase());
330 }
331 }
332 if cookie.path.is_none() {
334 let req_path = request_url.path();
335 let default_path = if let Some(pos) = req_path.rfind('/') {
336 if pos == 0 {
337 "/"
338 } else {
339 &req_path[..pos]
340 }
341 } else {
342 "/"
343 };
344 cookie.path = Some(default_path.to_string());
345 }
346 cookie.expires_at = cookie.max_age.map(|dur| std::time::Instant::now() + dur);
348
349 let name = cookie.name.clone();
351 let domain = cookie.domain.clone();
352 let path = cookie.path.clone();
353 self.cookies
354 .retain(|c| !(c.name == name && c.domain == domain && c.path == path));
355 self.cookies.push(cookie);
356 }
357
358 pub fn cookies_for_url(&self, url: &http::Uri) -> Vec<&Cookie> {
362 let now = std::time::Instant::now();
363 let host = url.host().unwrap_or("");
364 let path = url.path();
365 let is_secure = url.scheme_str() == Some("https");
366
367 let mut matched: Vec<&Cookie> = self
368 .cookies
369 .iter()
370 .filter(|c| {
371 if let Some(exp) = c.expires_at {
373 if exp <= now {
374 return false;
375 }
376 }
377 if c.secure && !is_secure {
379 return false;
380 }
381 let cookie_domain = c.domain.as_deref().unwrap_or("");
383 if !cookie_domain.is_empty() && !domain_match(cookie_domain, host) {
384 return false;
385 }
386 let cookie_path = c.path.as_deref().unwrap_or("/");
388 if !path_match(cookie_path, path) {
389 return false;
390 }
391 true
392 })
393 .collect();
394
395 matched.sort_by(|a, b| {
397 let a_len = a.path.as_deref().unwrap_or("/").len();
398 let b_len = b.path.as_deref().unwrap_or("/").len();
399 b_len.cmp(&a_len)
400 });
401 matched
402 }
403
404 pub fn to_cookie_header_for_url(&self, url: &http::Uri) -> Option<String> {
406 let matched = self.cookies_for_url(url);
407 if matched.is_empty() {
408 return None;
409 }
410 Some(
411 matched
412 .iter()
413 .map(|c| format!("{}={}", c.name, c.value))
414 .collect::<Vec<_>>()
415 .join("; "),
416 )
417 }
418
419 pub fn add_from_response_headers(&mut self, headers: &http::HeaderMap, url: &http::Uri) {
422 for value in headers.get_all(http::header::SET_COOKIE) {
423 if let Ok(s) = value.to_str() {
424 if let Some(cookie) = Cookie::parse_set_cookie(s) {
425 self.insert_for_url(cookie, url);
426 }
427 }
428 }
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn test_parse_simple_cookie() {
438 let cookie = Cookie::parse_set_cookie("session=abc123").expect("parse cookie");
439 assert_eq!(cookie.name(), "session");
440 assert_eq!(cookie.value(), "abc123");
441 }
442
443 #[test]
444 fn test_parse_full_cookie() {
445 let cookie = Cookie::parse_set_cookie(
446 "id=a3fWa; Domain=.example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax",
447 )
448 .expect("parse full cookie");
449 assert_eq!(cookie.name(), "id");
450 assert_eq!(cookie.value(), "a3fWa");
451 assert_eq!(cookie.domain(), Some(".example.com"));
452 assert_eq!(cookie.path(), Some("/"));
453 assert_eq!(cookie.max_age(), Some(Duration::from_secs(3600)));
454 assert!(cookie.is_secure());
455 assert!(cookie.is_http_only());
456 assert_eq!(cookie.same_site(), Some(SameSite::Lax));
457 }
458
459 #[test]
460 fn test_cookie_display() {
461 let cookie = Cookie::new("session", "abc")
462 .set_domain(".example.com")
463 .set_path("/")
464 .set_secure(true)
465 .set_http_only(true);
466 let s = cookie.to_string();
467 assert!(s.contains("session=abc"));
468 assert!(s.contains("Domain=.example.com"));
469 assert!(s.contains("Secure"));
470 assert!(s.contains("HttpOnly"));
471 }
472
473 #[test]
474 fn test_cookie_jar_operations() {
475 let mut jar = CookieJar::new();
476 assert!(jar.is_empty());
477
478 jar.insert(Cookie::new("a", "1"));
479 jar.insert(Cookie::new("b", "2"));
480 assert_eq!(jar.len(), 2);
481
482 assert_eq!(jar.get("a").map(|c| c.value()), Some("1"));
483 jar.remove("a");
484 assert_eq!(jar.len(), 1);
485 assert!(jar.get("a").is_none());
486 }
487
488 #[test]
489 fn test_cookie_jar_header() {
490 let mut jar = CookieJar::new();
491 jar.insert(Cookie::new("a", "1"));
492 jar.insert(Cookie::new("b", "2"));
493 let header = jar.to_cookie_header();
494 assert!(header.contains("a=1"));
496 assert!(header.contains("b=2"));
497 }
498
499 #[test]
500 fn test_add_from_set_cookie_headers() {
501 let mut jar = CookieJar::new();
502 jar.add_from_set_cookie_headers(vec!["session=abc; HttpOnly", "lang=en; Path=/"]);
503 assert_eq!(jar.len(), 2);
504 assert!(jar.get("session").expect("session").is_http_only());
505 assert_eq!(jar.get("lang").expect("lang").path(), Some("/"));
506 }
507
508 #[test]
509 fn test_empty_name_rejected() {
510 let result = Cookie::parse_set_cookie("=value");
511 assert!(result.is_none());
512 }
513
514 #[test]
515 fn test_domain_match_exact() {
516 assert!(domain_match("example.com", "example.com"));
517 assert!(domain_match(".example.com", "example.com")); assert!(!domain_match("example.com", "other.com"));
519 }
520
521 #[test]
522 fn test_domain_match_suffix() {
523 assert!(domain_match("example.com", "sub.example.com"));
524 assert!(domain_match(".example.com", "sub.example.com"));
525 assert!(!domain_match("example.com", "notexample.com"));
526 }
527
528 #[test]
529 fn test_path_match() {
530 assert!(path_match("/", "/foo/bar"));
531 assert!(path_match("/foo", "/foo/bar"));
532 assert!(path_match("/foo/", "/foo/bar"));
533 assert!(!path_match("/foo", "/foobar")); assert!(path_match("/foo", "/foo"));
535 }
536
537 #[test]
538 fn test_insert_for_url_defaults() {
539 use http::Uri;
540 let url: Uri = "http://example.com/api/v1/items".parse().expect("uri");
541 let cookie = Cookie::new("session", "abc");
542 let mut jar = CookieJar::new();
543 jar.insert_for_url(cookie, &url);
544 assert_eq!(jar.cookies[0].domain.as_deref(), Some("example.com"));
545 assert_eq!(jar.cookies[0].path.as_deref(), Some("/api/v1"));
546 }
547
548 #[test]
549 fn test_cookies_for_url() {
550 use http::Uri;
551 let url: Uri = "http://example.com/api/items".parse().expect("uri");
552 let mut jar = CookieJar::new();
553 let c = Cookie::new("session", "abc");
554 jar.insert_for_url(c, &url);
555
556 let matches = jar.cookies_for_url(&url);
557 assert_eq!(matches.len(), 1);
558 assert_eq!(matches[0].name, "session");
559
560 let other_url: Uri = "http://other.com/api/items".parse().expect("uri");
562 let no_matches = jar.cookies_for_url(&other_url);
563 assert!(no_matches.is_empty());
564 }
565
566 #[test]
567 fn test_expired_cookie_not_returned() {
568 use http::Uri;
569 let url: Uri = "http://example.com/".parse().expect("uri");
570 let mut c = Cookie::new("old", "val");
571 c.max_age = Some(std::time::Duration::from_secs(0)); let mut jar = CookieJar::new();
573 jar.insert_for_url(c, &url);
574 if let Some(cookie) = jar.cookies.first_mut() {
576 cookie.expires_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
577 }
578 assert!(jar.cookies_for_url(&url).is_empty());
579 }
580
581 #[test]
582 fn test_secure_cookie_https_only() {
583 use http::Uri;
584 let https_url: Uri = "https://example.com/".parse().expect("uri");
585 let http_url: Uri = "http://example.com/".parse().expect("uri");
586 let mut c = Cookie::new("secure_token", "xyz");
587 c.secure = true;
588 let mut jar = CookieJar::new();
589 jar.insert_for_url(c, &https_url);
590 assert_eq!(jar.cookies_for_url(&https_url).len(), 1);
592 assert!(jar.cookies_for_url(&http_url).is_empty());
593 }
594
595 #[test]
596 fn test_same_name_different_domain_coexist() {
597 use http::Uri;
598 let url_a: Uri = "http://a.example.com/".parse().expect("uri");
599 let url_b: Uri = "http://b.example.com/".parse().expect("uri");
600 let mut jar = CookieJar::new();
601 jar.insert_for_url(Cookie::new("token", "aaa"), &url_a);
602 jar.insert_for_url(Cookie::new("token", "bbb"), &url_b);
603 assert_eq!(jar.cookies.len(), 2);
604 assert_eq!(jar.cookies_for_url(&url_a)[0].value, "aaa");
605 assert_eq!(jar.cookies_for_url(&url_b)[0].value, "bbb");
606 }
607
608 use proptest::prelude::*;
613
614 fn cookie_name_strategy() -> impl Strategy<Value = String> {
617 proptest::string::string_regex("[a-zA-Z][a-zA-Z0-9]{0,31}").expect("valid regex")
618 }
619
620 fn cookie_value_strategy() -> impl Strategy<Value = String> {
623 proptest::string::string_regex("[a-zA-Z0-9]{0,64}").expect("valid regex")
624 }
625
626 proptest! {
627 #[test]
628 fn prop_cookie_round_trip(
629 name in cookie_name_strategy(),
630 value in cookie_value_strategy(),
631 ) {
632 let original = Cookie::new(name.clone(), value.clone());
633 let serialized = original.to_string();
634 let parsed = Cookie::parse_set_cookie(&serialized)
635 .expect("round-trip parse must succeed for valid name/value");
636 prop_assert_eq!(&parsed.name, &name);
637 prop_assert_eq!(&parsed.value, &value);
638 }
639 }
640
641 #[test]
643 fn test_cookie_round_trip_known_cases() {
644 let cases = [
645 ("session", "abc123"),
646 ("LANG", "en"),
647 ("x", ""),
648 ("Token", "abcdefghijklmnopqrstuvwxyz"),
649 ("A1B2", "Z9Y8X7"),
650 ];
651 for (name, value) in cases {
652 let original = Cookie::new(name, value);
653 let serialized = original.to_string();
654 let parsed = Cookie::parse_set_cookie(&serialized)
655 .unwrap_or_else(|| panic!("failed to parse round-trip for {name}={value}"));
656 assert_eq!(parsed.name, name, "name mismatch for {name}");
657 assert_eq!(parsed.value, value, "value mismatch for {name}={value}");
658 }
659 }
660}