1use axum::http::{HeaderName, HeaderValue, Request, Response};
41use chrono::{DateTime, Utc};
42use std::collections::HashMap;
43
44const FOREVER_EXPIRE_SECONDS: i64 = 315360000;
50
51const DELETE_EXPIRE_OFFSET_SECONDS: i64 = -3600;
53
54#[derive(Debug, Clone)]
72pub struct CookieOptions {
73 pub expire: i64,
76 pub path: String,
78 pub domain: String,
80 pub secure: bool,
82 pub httponly: bool,
84 pub samesite: String,
86}
87
88impl Default for CookieOptions {
89 fn default() -> Self {
90 Self {
91 expire: 0,
92 path: "/".to_string(),
93 domain: String::new(),
94 secure: false,
95 httponly: false,
96 samesite: String::new(),
97 }
98 }
99}
100
101impl CookieOptions {
102 pub fn with_expire(expire: i64) -> Self {
107 Self {
108 expire,
109 ..Default::default()
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
122pub struct CookieEntry {
123 pub name: String,
125 pub value: String,
127 pub expire: i64,
129 pub options: CookieOptions,
131}
132
133impl CookieEntry {
134 pub fn to_header_string(&self) -> String {
143 let mut parts = vec![format!("{}={}", self.name, self.value)];
144
145 if self.expire > 0 {
147 let expire_dt =
148 DateTime::<Utc>::from_timestamp(self.expire, 0).unwrap_or_else(Utc::now);
149 parts.push(format!(
151 "Expires={}",
152 expire_dt.format("%a, %d %b %Y %H:%M:%S GMT")
153 ));
154 }
155
156 if !self.options.path.is_empty() {
158 parts.push(format!("Path={}", self.options.path));
159 }
160
161 if !self.options.domain.is_empty() {
163 parts.push(format!("Domain={}", self.options.domain));
164 }
165
166 if self.options.secure {
168 parts.push("Secure".to_string());
169 }
170
171 if self.options.httponly {
173 parts.push("HttpOnly".to_string());
174 }
175
176 if !self.options.samesite.is_empty() {
178 parts.push(format!("SameSite={}", self.options.samesite));
179 }
180
181 parts.join("; ")
182 }
183}
184
185#[derive(Debug, Clone, Default)]
220pub struct CookieJar {
221 request_cookies: HashMap<String, String>,
223 response_cookies: Vec<CookieEntry>,
225 config: CookieOptions,
227}
228
229impl CookieJar {
230 pub fn new() -> Self {
232 Self::default()
233 }
234
235 pub fn with_config(config: CookieOptions) -> Self {
239 Self {
240 request_cookies: HashMap::new(),
241 response_cookies: Vec::new(),
242 config,
243 }
244 }
245
246 pub fn from_request<B>(req: &Request<B>) -> Self {
254 let mut jar = Self::default();
255 if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) {
256 if let Ok(header_str) = cookie_header.to_str() {
257 jar.request_cookies = parse_cookie_header(header_str);
258 }
259 }
260 jar
261 }
262
263 pub fn get(&self, name: &str) -> Option<String> {
270 self.request_cookies.get(name).cloned()
271 }
272
273 pub fn get_with_default(&self, name: &str, default: &str) -> String {
277 self.request_cookies
278 .get(name)
279 .cloned()
280 .unwrap_or_else(|| default.to_string())
281 }
282
283 pub fn has(&self, name: &str) -> bool {
285 self.request_cookies.contains_key(name)
286 }
287
288 pub fn set(mut self, name: &str, value: &str, options: CookieOptions) -> Self {
302 let expire = if options.expire > 0 {
304 Utc::now().timestamp() + options.expire
305 } else {
306 0
307 };
308
309 self.response_cookies.push(CookieEntry {
310 name: name.to_string(),
311 value: value.to_string(),
312 expire,
313 options,
314 });
315 self
316 }
317
318 pub fn forever(self, name: &str, value: &str, mut options: CookieOptions) -> Self {
322 options.expire = FOREVER_EXPIRE_SECONDS;
323 self.set(name, value, options)
324 }
325
326 pub fn delete(mut self, name: &str, options: CookieOptions) -> Self {
330 let expire = Utc::now().timestamp() + DELETE_EXPIRE_OFFSET_SECONDS;
331 self.response_cookies.push(CookieEntry {
332 name: name.to_string(),
333 value: String::new(),
334 expire,
335 options,
336 });
337 self
338 }
339
340 pub fn apply_to_response<B>(self, resp: &mut Response<B>) {
344 if self.response_cookies.is_empty() {
345 return;
346 }
347
348 let headers = resp.headers_mut();
349 for entry in &self.response_cookies {
350 if let Ok(value) = HeaderValue::from_str(&entry.to_header_string()) {
352 headers.append(HeaderName::from_static("set-cookie"), value);
353 }
354 }
355 }
356
357 pub fn get_response_cookies(&self) -> &[CookieEntry] {
359 &self.response_cookies
360 }
361
362 pub fn config(&self) -> &CookieOptions {
364 &self.config
365 }
366}
367
368fn parse_cookie_header(header: &str) -> HashMap<String, String> {
381 let mut cookies = HashMap::new();
382 for pair in header.split(';') {
383 let pair = pair.trim();
384 if pair.is_empty() {
385 continue;
386 }
387 if let Some(eq_pos) = pair.find('=') {
388 let name = pair[..eq_pos].trim().to_string();
389 let value = pair[eq_pos + 1..].trim().to_string();
390 if !name.is_empty() {
391 cookies.insert(name, value);
392 }
393 }
394 }
395 cookies
396}
397
398#[cfg(test)]
403mod tests {
404 use super::*;
405 use axum::body::Body;
406 use axum::http::{Request, Response};
407
408 #[test]
413 fn test_cookie_options_default() {
414 let opts = CookieOptions::default();
415 assert_eq!(opts.expire, 0);
416 assert_eq!(opts.path, "/");
417 assert_eq!(opts.domain, "");
418 assert!(!opts.secure);
419 assert!(!opts.httponly);
420 assert_eq!(opts.samesite, "");
421 }
422
423 #[test]
424 fn test_cookie_options_with_expire() {
425 let opts = CookieOptions::with_expire(3600);
426 assert_eq!(opts.expire, 3600);
427 assert_eq!(opts.path, "/"); }
429
430 #[test]
435 fn test_cookie_entry_minimal_header() {
436 let entry = CookieEntry {
438 name: "token".to_string(),
439 value: "abc123".to_string(),
440 expire: 0,
441 options: CookieOptions {
442 path: String::new(), domain: String::new(),
444 samesite: String::new(),
445 ..Default::default()
446 },
447 };
448 let header = entry.to_header_string();
449 assert_eq!(header, "token=abc123");
450 }
451
452 #[test]
453 fn test_cookie_entry_with_path() {
454 let entry = CookieEntry {
455 name: "token".to_string(),
456 value: "abc".to_string(),
457 expire: 0,
458 options: CookieOptions {
459 path: "/api".to_string(),
460 ..Default::default()
461 },
462 };
463 let header = entry.to_header_string();
464 assert!(header.contains("token=abc"));
465 assert!(header.contains("Path=/api"));
466 }
467
468 #[test]
469 fn test_cookie_entry_with_all_attributes() {
470 let entry = CookieEntry {
471 name: "session".to_string(),
472 value: "xyz".to_string(),
473 expire: 1893456000, options: CookieOptions {
475 path: "/".to_string(),
476 domain: "example.com".to_string(),
477 secure: true,
478 httponly: true,
479 samesite: "Lax".to_string(),
480 ..Default::default()
481 },
482 };
483 let header = entry.to_header_string();
484 assert!(header.contains("session=xyz"));
485 assert!(header.contains("Expires="));
486 assert!(header.contains("Path=/"));
487 assert!(header.contains("Domain=example.com"));
488 assert!(header.contains("Secure"));
489 assert!(header.contains("HttpOnly"));
490 assert!(header.contains("SameSite=Lax"));
491 }
492
493 #[test]
494 fn test_cookie_entry_expire_zero_no_expires_header() {
495 let entry = CookieEntry {
497 name: "session".to_string(),
498 value: "v".to_string(),
499 expire: 0,
500 options: CookieOptions::default(),
501 };
502 let header = entry.to_header_string();
503 assert!(!header.contains("Expires="));
504 }
505
506 #[test]
511 fn test_cookie_jar_default_empty() {
512 let jar = CookieJar::new();
513 assert!(jar.get("any").is_none());
514 assert!(!jar.has("any"));
515 assert!(jar.get_response_cookies().is_empty());
516 }
517
518 #[test]
519 fn test_cookie_jar_with_config() {
520 let config = CookieOptions {
521 path: "/app".to_string(),
522 ..Default::default()
523 };
524 let jar = CookieJar::with_config(config);
525 assert_eq!(jar.config().path, "/app");
526 }
527
528 #[test]
529 fn test_cookie_jar_set_adds_to_response_cookies() {
530 let jar = CookieJar::new().set("token", "abc", CookieOptions::default());
531 assert_eq!(jar.get_response_cookies().len(), 1);
532 assert_eq!(jar.get_response_cookies()[0].name, "token");
533 assert_eq!(jar.get_response_cookies()[0].value, "abc");
534 }
535
536 #[test]
537 fn test_cookie_jar_set_chain() {
538 let jar = CookieJar::new()
540 .set("a", "1", CookieOptions::default())
541 .set("b", "2", CookieOptions::default())
542 .set("c", "3", CookieOptions::default());
543 assert_eq!(jar.get_response_cookies().len(), 3);
544 }
545
546 #[test]
547 fn test_cookie_jar_set_with_expire_calculates_timestamp() {
548 let before = Utc::now().timestamp();
549 let jar = CookieJar::new().set("token", "abc", CookieOptions::with_expire(3600));
550 let after = Utc::now().timestamp();
551
552 let entry = &jar.get_response_cookies()[0];
553 assert!(entry.expire >= before + 3600);
555 assert!(entry.expire <= after + 3600);
556 }
557
558 #[test]
559 fn test_cookie_jar_set_expire_zero_keeps_zero() {
560 let jar = CookieJar::new().set("token", "abc", CookieOptions::default());
561 let entry = &jar.get_response_cookies()[0];
562 assert_eq!(entry.expire, 0);
563 }
564
565 #[test]
566 fn test_cookie_jar_forever_sets_10_year_expire() {
567 let before = Utc::now().timestamp();
568 let jar = CookieJar::new().forever("token", "abc", CookieOptions::default());
569
570 let entry = &jar.get_response_cookies()[0];
571 let expected_min = before + FOREVER_EXPIRE_SECONDS;
573 assert!(entry.expire >= expected_min);
574 }
575
576 #[test]
577 fn test_cookie_jar_delete_sets_past_expire() {
578 let before = Utc::now().timestamp();
579 let jar = CookieJar::new().delete("token", CookieOptions::default());
580
581 let entry = &jar.get_response_cookies()[0];
582 assert_eq!(entry.value, ""); assert!(entry.expire < before);
585 }
586
587 #[test]
592 fn test_from_request_no_cookie_header() {
593 let req = Request::<Body>::default();
594 let jar = CookieJar::from_request(&req);
595 assert!(jar.get("any").is_none());
596 }
597
598 #[test]
599 fn test_from_request_single_cookie() {
600 let mut req = Request::<Body>::default();
601 req.headers_mut().insert(
602 axum::http::header::COOKIE,
603 HeaderValue::from_static("token=abc123"),
604 );
605 let jar = CookieJar::from_request(&req);
606 assert_eq!(jar.get("token"), Some("abc123".to_string()));
607 assert!(jar.has("token"));
608 }
609
610 #[test]
611 fn test_from_request_multiple_cookies() {
612 let mut req = Request::<Body>::default();
613 req.headers_mut().insert(
614 axum::http::header::COOKIE,
615 HeaderValue::from_static("token=abc; user=42; theme=dark"),
616 );
617 let jar = CookieJar::from_request(&req);
618 assert_eq!(jar.get("token"), Some("abc".to_string()));
619 assert_eq!(jar.get("user"), Some("42".to_string()));
620 assert_eq!(jar.get("theme"), Some("dark".to_string()));
621 }
622
623 #[test]
624 fn test_from_request_cookie_with_whitespace() {
625 let mut req = Request::<Body>::default();
626 req.headers_mut().insert(
627 axum::http::header::COOKIE,
628 HeaderValue::from_static(" token = abc ; user = 42 "),
629 );
630 let jar = CookieJar::from_request(&req);
631 assert_eq!(jar.get("token"), Some("abc".to_string()));
632 assert_eq!(jar.get("user"), Some("42".to_string()));
633 }
634
635 #[test]
636 fn test_from_request_cookie_value_with_equals() {
637 let mut req = Request::<Body>::default();
639 req.headers_mut().insert(
640 axum::http::header::COOKIE,
641 HeaderValue::from_static("data=a=b=c"),
642 );
643 let jar = CookieJar::from_request(&req);
644 assert_eq!(jar.get("data"), Some("a=b=c".to_string()));
645 }
646
647 #[test]
648 fn test_from_request_empty_cookie_header() {
649 let mut req = Request::<Body>::default();
650 req.headers_mut()
651 .insert(axum::http::header::COOKIE, HeaderValue::from_static(""));
652 let jar = CookieJar::from_request(&req);
653 assert!(jar.get("any").is_none());
654 }
655
656 #[test]
657 fn test_from_request_malformed_pairs_ignored() {
658 let mut req = Request::<Body>::default();
659 req.headers_mut().insert(
660 axum::http::header::COOKIE,
661 HeaderValue::from_static("token=abc; malformed; =empty_name; valid=ok"),
662 );
663 let jar = CookieJar::from_request(&req);
664 assert_eq!(jar.get("token"), Some("abc".to_string()));
665 assert!(jar.get("malformed").is_none()); assert!(jar.get("").is_none()); assert_eq!(jar.get("valid"), Some("ok".to_string()));
668 }
669
670 #[test]
671 fn test_get_with_default_returns_value_when_exists() {
672 let mut req = Request::<Body>::default();
673 req.headers_mut().insert(
674 axum::http::header::COOKIE,
675 HeaderValue::from_static("name=alice"),
676 );
677 let jar = CookieJar::from_request(&req);
678 assert_eq!(jar.get_with_default("name", "guest"), "alice");
679 }
680
681 #[test]
682 fn test_get_with_default_returns_default_when_missing() {
683 let req = Request::<Body>::default();
684 let jar = CookieJar::from_request(&req);
685 assert_eq!(jar.get_with_default("name", "guest"), "guest");
686 }
687
688 #[test]
693 fn test_apply_to_response_no_cookies() {
694 let jar = CookieJar::new();
695 let mut resp = Response::new(Body::empty());
696 jar.apply_to_response(&mut resp);
697 assert!(resp.headers().get("set-cookie").is_none());
698 }
699
700 #[test]
701 fn test_apply_to_response_single_cookie() {
702 let jar = CookieJar::new().set("token", "abc", CookieOptions::default());
703 let mut resp = Response::new(Body::empty());
704 jar.apply_to_response(&mut resp);
705
706 let set_cookies: Vec<_> = resp.headers().get_all("set-cookie").iter().collect();
707 assert_eq!(set_cookies.len(), 1);
708 assert_eq!(set_cookies[0].to_str().unwrap(), "token=abc; Path=/");
709 }
710
711 #[test]
712 fn test_apply_to_response_multiple_cookies() {
713 let jar = CookieJar::new()
714 .set("a", "1", CookieOptions::default())
715 .set("b", "2", CookieOptions::default())
716 .set("c", "3", CookieOptions::default());
717 let mut resp = Response::new(Body::empty());
718 jar.apply_to_response(&mut resp);
719
720 let set_cookies: Vec<_> = resp
721 .headers()
722 .get_all("set-cookie")
723 .iter()
724 .map(|v| v.to_str().unwrap().to_string())
725 .collect();
726 assert_eq!(set_cookies.len(), 3);
727 assert!(set_cookies.contains(&"a=1; Path=/".to_string()));
728 assert!(set_cookies.contains(&"b=2; Path=/".to_string()));
729 assert!(set_cookies.contains(&"c=3; Path=/".to_string()));
730 }
731
732 #[test]
733 fn test_apply_to_response_with_all_attributes() {
734 let jar = CookieJar::new().set(
735 "session",
736 "xyz",
737 CookieOptions {
738 expire: 1893456000, path: "/".to_string(),
740 domain: "example.com".to_string(),
741 secure: true,
742 httponly: true,
743 samesite: "Strict".to_string(),
744 },
745 );
746 let mut resp = Response::new(Body::empty());
747 jar.apply_to_response(&mut resp);
748
749 let header = resp
750 .headers()
751 .get("set-cookie")
752 .unwrap()
753 .to_str()
754 .unwrap()
755 .to_string();
756 assert!(header.contains("session=xyz"));
757 assert!(header.contains("Expires="));
758 assert!(header.contains("Path=/"));
759 assert!(header.contains("Domain=example.com"));
760 assert!(header.contains("Secure"));
761 assert!(header.contains("HttpOnly"));
762 assert!(header.contains("SameSite=Strict"));
763 }
764
765 #[test]
770 fn test_parse_empty_header() {
771 let cookies = parse_cookie_header("");
772 assert!(cookies.is_empty());
773 }
774
775 #[test]
776 fn test_parse_single_pair() {
777 let cookies = parse_cookie_header("name=value");
778 assert_eq!(cookies.get("name"), Some(&"value".to_string()));
779 }
780
781 #[test]
782 fn test_parse_multiple_pairs() {
783 let cookies = parse_cookie_header("a=1; b=2; c=3");
784 assert_eq!(cookies.len(), 3);
785 assert_eq!(cookies.get("a"), Some(&"1".to_string()));
786 assert_eq!(cookies.get("b"), Some(&"2".to_string()));
787 assert_eq!(cookies.get("c"), Some(&"3".to_string()));
788 }
789
790 #[test]
791 fn test_parse_trims_whitespace() {
792 let cookies = parse_cookie_header(" a = 1 ; b = 2 ");
793 assert_eq!(cookies.get("a"), Some(&"1".to_string()));
794 assert_eq!(cookies.get("b"), Some(&"2".to_string()));
795 }
796
797 #[test]
798 fn test_parse_skips_empty_pairs() {
799 let cookies = parse_cookie_header("a=1;; ;b=2");
800 assert_eq!(cookies.len(), 2);
801 assert_eq!(cookies.get("a"), Some(&"1".to_string()));
802 assert_eq!(cookies.get("b"), Some(&"2".to_string()));
803 }
804
805 #[test]
806 fn test_parse_skips_no_equals() {
807 let cookies = parse_cookie_header("a=1; invalid; b=2");
808 assert_eq!(cookies.len(), 2);
809 assert!(!cookies.contains_key("invalid"));
810 }
811
812 #[test]
813 fn test_parse_skips_empty_name() {
814 let cookies = parse_cookie_header("a=1; =empty; b=2");
815 assert_eq!(cookies.len(), 2);
816 assert!(!cookies.contains_key(""));
817 }
818
819 #[test]
824 fn test_php_consistency_set_and_save_flow() {
825 let req = Request::<Body>::default();
827 let jar =
828 CookieJar::from_request(&req).set("token", "abc123", CookieOptions::with_expire(3600));
829
830 let mut resp = Response::new(Body::empty());
831 jar.apply_to_response(&mut resp);
832
833 let header = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
834 assert!(header.starts_with("token=abc123"));
835 assert!(header.contains("Expires="));
836 assert!(header.contains("Path=/"));
837 }
838
839 #[test]
840 fn test_php_consistency_delete_flow() {
841 let req = Request::<Body>::default();
843 let jar = CookieJar::from_request(&req).delete("token", CookieOptions::default());
844
845 let mut resp = Response::new(Body::empty());
846 jar.apply_to_response(&mut resp);
847
848 let header = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
849 assert!(header.starts_with("token="));
851 assert!(header.contains("Expires="));
852 }
853
854 #[test]
855 fn test_php_consistency_forever_flow() {
856 let req = Request::<Body>::default();
857 let jar = CookieJar::from_request(&req).forever("pref", "dark", CookieOptions::default());
858
859 let mut resp = Response::new(Body::empty());
860 jar.apply_to_response(&mut resp);
861
862 let header = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
863 assert!(header.contains("pref=dark"));
864 assert!(header.contains("Expires="));
865 }
866
867 #[test]
868 fn test_php_consistency_request_response_isolation() {
869 let mut req = Request::<Body>::default();
871 req.headers_mut().insert(
872 axum::http::header::COOKIE,
873 HeaderValue::from_static("old=value"),
874 );
875 let jar = CookieJar::from_request(&req).set("new", "value", CookieOptions::default());
876
877 assert_eq!(jar.get("old"), Some("value".to_string()));
879 assert!(jar.get("new").is_none());
881 assert_eq!(jar.get_response_cookies().len(), 1);
883 assert_eq!(jar.get_response_cookies()[0].name, "new");
884 }
885}