1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3mod jar;
4mod macros;
5mod map;
6mod prefix;
7mod private;
8#[cfg(feature = "router")]
9mod router;
10mod signed;
11mod store;
12
13use std::sync::{
14 OnceLock,
15 atomic::{AtomicBool, Ordering},
16};
17
18pub use cookie::{Cookie, Expiration, Key, SameSite, time};
19pub use jar::*;
20pub use map::*;
21use prefix::Conform;
22pub use prefix::*;
23pub use private::*;
24#[cfg(feature = "router")]
25pub use router::*;
26pub use signed::*;
27pub use store::*;
28use topcoat_core::context::{Cx, app_context, request_context};
29
30pub trait Cookies {
38 fn get(&self, name: &str) -> Option<Cookie<'static>>;
41
42 fn add<C: Into<Cookie<'static>>>(&self, cookie: C);
51
52 fn remove<C: Into<Cookie<'static>>>(&self, cookie: C);
62
63 fn signed(self, key: &Key) -> SignedJar<'_, Self>
65 where
66 Self: Sized,
67 {
68 SignedJar::new(self, key)
69 }
70
71 fn private(self, key: &Key) -> PrivateJar<'_, Self>
73 where
74 Self: Sized,
75 {
76 PrivateJar::new(self, key)
77 }
78
79 fn map<F>(self, f: F) -> Map<Self, F>
82 where
83 Self: Sized,
84 F: Fn(&mut Cookie<'static>),
85 {
86 Map::new(self, f)
87 }
88
89 fn override_prefix_host(self) -> Prefixed<Self>
92 where
93 Self: Sized,
94 {
95 Prefixed::new(self, Prefix::Host, Conform::Override)
96 }
97
98 fn default_prefix_host(self) -> Prefixed<Self>
101 where
102 Self: Sized,
103 {
104 Prefixed::new(self, Prefix::Host, Conform::Default)
105 }
106
107 fn override_prefix_secure(self) -> Prefixed<Self>
110 where
111 Self: Sized,
112 {
113 Prefixed::new(self, Prefix::Secure, Conform::Override)
114 }
115
116 fn default_prefix_secure(self) -> Prefixed<Self>
119 where
120 Self: Sized,
121 {
122 Prefixed::new(self, Prefix::Secure, Conform::Default)
123 }
124
125 fn override_secure(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
127 where
128 Self: Sized,
129 {
130 self.map(move |cookie| cookie.set_secure(value))
131 }
132
133 fn default_secure(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
135 where
136 Self: Sized,
137 {
138 self.map(move |cookie| {
139 if cookie.secure().is_none() {
140 cookie.set_secure(value);
141 }
142 })
143 }
144
145 fn override_http_only(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
147 where
148 Self: Sized,
149 {
150 self.map(move |cookie| cookie.set_http_only(value))
151 }
152
153 fn default_http_only(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
155 where
156 Self: Sized,
157 {
158 self.map(move |cookie| {
159 if cookie.http_only().is_none() {
160 cookie.set_http_only(value);
161 }
162 })
163 }
164
165 fn override_same_site(self, value: SameSite) -> Map<Self, impl Fn(&mut Cookie<'static>)>
167 where
168 Self: Sized,
169 {
170 self.map(move |cookie| cookie.set_same_site(value))
171 }
172
173 fn default_same_site(self, value: SameSite) -> Map<Self, impl Fn(&mut Cookie<'static>)>
175 where
176 Self: Sized,
177 {
178 self.map(move |cookie| {
179 if cookie.same_site().is_none() {
180 cookie.set_same_site(value);
181 }
182 })
183 }
184
185 fn override_path(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
187 where
188 Self: Sized,
189 {
190 let value = value.into();
191 self.map(move |cookie| cookie.set_path(value.clone()))
192 }
193
194 fn default_path(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
196 where
197 Self: Sized,
198 {
199 let value = value.into();
200 self.map(move |cookie| {
201 if cookie.path().is_none() {
202 cookie.set_path(value.clone());
203 }
204 })
205 }
206
207 fn override_domain(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
209 where
210 Self: Sized,
211 {
212 let value = value.into();
213 self.map(move |cookie| cookie.set_domain(value.clone()))
214 }
215
216 fn default_domain(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
218 where
219 Self: Sized,
220 {
221 let value = value.into();
222 self.map(move |cookie| {
223 if cookie.domain().is_none() {
224 cookie.set_domain(value.clone());
225 }
226 })
227 }
228
229 fn override_max_age(self, value: time::Duration) -> Map<Self, impl Fn(&mut Cookie<'static>)>
231 where
232 Self: Sized,
233 {
234 self.map(move |cookie| cookie.set_max_age(value))
235 }
236
237 fn default_max_age(self, value: time::Duration) -> Map<Self, impl Fn(&mut Cookie<'static>)>
239 where
240 Self: Sized,
241 {
242 self.map(move |cookie| {
243 if cookie.max_age().is_none() {
244 cookie.set_max_age(value);
245 }
246 })
247 }
248}
249
250#[derive(Debug, Default)]
261pub struct CookieJarCell {
262 jar: OnceLock<CookieJar>,
263 sealed: AtomicBool,
264}
265
266impl CookieJarCell {
267 #[must_use]
268 pub fn new() -> Self {
269 Self::default()
270 }
271
272 fn get_or_init(&self, cx: &Cx) -> &CookieJar {
273 let jar = self.jar.get_or_init(|| CookieJar::from_request(cx));
274 if self.sealed.load(Ordering::Acquire) {
278 jar.seal();
279 }
280 jar
281 }
282
283 fn get(&self) -> Option<&CookieJar> {
284 self.jar.get()
285 }
286
287 fn seal(&self) {
289 self.sealed.store(true, Ordering::Release);
290 if let Some(jar) = self.jar.get() {
291 jar.seal();
292 }
293 }
294}
295
296#[must_use]
306#[track_caller]
307pub fn cookies(cx: &Cx) -> &CookieJar {
308 request_context::<CookieJarCell>(cx).get_or_init(cx)
309}
310
311#[must_use]
318#[track_caller]
319pub fn signed_cookies(cx: &Cx) -> SignedJar<'_, &CookieJar> {
320 cookies(cx).signed(app_context::<Key>(cx))
321}
322
323#[must_use]
330#[track_caller]
331pub fn private_cookies(cx: &Cx) -> PrivateJar<'_, &CookieJar> {
332 cookies(cx).private(app_context::<Key>(cx))
333}
334
335#[doc(hidden)]
345pub fn write_cookies(cx: &Cx, headers: &mut http::HeaderMap) {
346 let cell = request_context::<CookieJarCell>(cx);
347 if let Some(jar) = cell.get() {
348 for value in jar.delta_headers() {
349 headers.append(http::header::SET_COOKIE, value);
350 }
351 }
352 cell.seal();
353}
354
355#[cfg(test)]
356mod tests {
357 use http::{HeaderMap, Request, header};
358 use topcoat_core::context::CxTestBuilder;
359
360 use super::*;
361
362 fn cx_with(cookie_headers: &[&str]) -> Cx {
366 let mut builder = Request::builder();
367 for value in cookie_headers {
368 builder = builder.header(header::COOKIE, *value);
369 }
370 let (parts, ()) = builder.body(()).unwrap().into_parts();
371
372 CxTestBuilder::new()
373 .request_context(parts)
374 .request_context(CookieJarCell::new())
375 .build()
376 }
377
378 fn cx_with_key(cookie_headers: &[&str], key: Key) -> Cx {
381 let mut builder = Request::builder();
382 for value in cookie_headers {
383 builder = builder.header(header::COOKIE, *value);
384 }
385 let (parts, ()) = builder.body(()).unwrap().into_parts();
386
387 CxTestBuilder::new()
388 .app_context(key)
389 .request_context(parts)
390 .request_context(CookieJarCell::new())
391 .build()
392 }
393
394 fn set_cookies(cx: &Cx) -> Vec<String> {
396 let mut headers = HeaderMap::new();
397 write_cookies(cx, &mut headers);
398 headers
399 .get_all(header::SET_COOKIE)
400 .iter()
401 .map(|value| value.to_str().unwrap().to_owned())
402 .collect()
403 }
404
405 fn pair(set_cookie: &str) -> &str {
408 set_cookie.split(';').next().unwrap()
409 }
410
411 #[test]
412 fn reads_incoming_cookies() {
413 let cx = cx_with(&["theme=dark; lang=en"]);
414 let jar = cookies(&cx);
415
416 assert_eq!(jar.get("theme").unwrap().value(), "dark");
417 assert_eq!(jar.get("lang").unwrap().value(), "en");
418 assert!(jar.get("missing").is_none());
419 }
420
421 #[test]
422 fn reads_cookies_across_multiple_headers() {
423 let cx = cx_with(&["theme=dark", "lang=en"]);
424 let jar = cookies(&cx);
425
426 assert_eq!(jar.get("theme").unwrap().value(), "dark");
427 assert_eq!(jar.get("lang").unwrap().value(), "en");
428 }
429
430 #[test]
431 fn reading_does_not_produce_a_delta() {
432 let cx = cx_with(&["theme=dark"]);
435 let _ = cookies(&cx).get("theme");
436
437 assert_eq!(set_cookies(&cx), Vec::<String>::new());
438 }
439
440 #[test]
441 fn add_emits_set_cookie() {
442 let cx = cx_with(&[]);
443 cookies(&cx).add(("theme", "dark"));
444
445 let set = set_cookies(&cx);
446 assert_eq!(set.len(), 1);
447 assert_eq!(pair(&set[0]), "theme=dark");
448 }
449
450 #[test]
451 fn remove_emits_expiring_cookie() {
452 let cx = cx_with(&["session=abc123"]);
453 cookies(&cx).remove(("session", ""));
454
455 let set = set_cookies(&cx);
456 assert_eq!(set.len(), 1);
457 assert!(set[0].starts_with("session="));
458 assert!(set[0].contains("Max-Age=0"), "{}", set[0]);
459 }
460
461 #[test]
462 fn remove_replays_path_default_so_browser_matches() {
463 let cx = cx_with(&["session=abc123"]);
466 cookies(&cx).default_path("/app").remove(("session", ""));
467
468 let set = &set_cookies(&cx)[0];
469 assert!(set.contains("Path=/app"), "{set}");
470 assert!(set.contains("Max-Age=0"), "{set}");
471 }
472
473 #[test]
474 fn remove_keeps_expiry_despite_max_age_default() {
475 let cx = cx_with(&["session=abc123"]);
478 cookies(&cx)
479 .default_max_age(time::Duration::hours(1))
480 .remove(("session", ""));
481
482 assert!(
483 set_cookies(&cx)[0].contains("Max-Age=0"),
484 "{:?}",
485 set_cookies(&cx)
486 );
487 }
488
489 #[test]
490 fn write_cookies_skips_when_jar_untouched() {
491 let cx = CxTestBuilder::new()
495 .request_context(CookieJarCell::new())
496 .build();
497 let mut headers = HeaderMap::new();
498 write_cookies(&cx, &mut headers);
499
500 assert!(headers.get(header::SET_COOKIE).is_none());
501 }
502
503 #[test]
504 fn reads_still_work_after_the_response() {
505 let cx = cx_with(&["theme=dark"]);
508 let _ = set_cookies(&cx);
509
510 assert_eq!(cookies(&cx).get("theme").unwrap().value(), "dark");
511 }
512
513 #[test]
514 #[should_panic(expected = "cannot add a cookie after the response")]
515 fn adding_after_the_response_panics() {
516 let cx = cx_with(&[]);
517 cookies(&cx).add(("theme", "dark"));
518 let _ = set_cookies(&cx);
519
520 cookies(&cx).add(("theme", "light"));
521 }
522
523 #[test]
524 #[should_panic(expected = "cannot remove a cookie after the response")]
525 fn removing_after_the_response_panics() {
526 let cx = cx_with(&["session=abc123"]);
527 let _ = set_cookies(&cx);
528
529 cookies(&cx).remove(("session", ""));
530 }
531
532 #[test]
533 #[should_panic(expected = "cannot add a cookie after the response")]
534 fn adding_through_a_jar_built_after_the_response_panics() {
535 let cx = cx_with(&["theme=dark"]);
538 let _ = set_cookies(&cx);
539
540 cookies(&cx).default_path("/").add(("theme", "light"));
541 }
542
543 #[test]
544 fn default_attribute_only_fills_when_unset() {
545 let cx = cx_with(&[]);
546 cookies(&cx)
548 .default_secure(true)
549 .add(Cookie::build(("a", "b")).secure(false).build());
550
551 assert!(!set_cookies(&cx)[0].contains("Secure"));
552 }
553
554 #[test]
555 fn override_attribute_replaces_existing() {
556 let cx = cx_with(&[]);
557 cookies(&cx)
558 .override_secure(true)
559 .add(Cookie::build(("a", "b")).secure(false).build());
560
561 assert!(set_cookies(&cx)[0].contains("Secure"));
562 }
563
564 #[test]
565 fn host_prefix_applies_name_and_attributes() {
566 let cx = cx_with(&[]);
567 cookies(&cx).override_prefix_host().add(("session", "abc"));
568
569 let set = &set_cookies(&cx)[0];
570 assert!(set.starts_with("__Host-session=abc"), "{set}");
571 assert!(set.contains("Secure"), "{set}");
572 assert!(set.contains("Path=/"), "{set}");
573 }
574
575 #[test]
576 fn host_prefix_reads_back_under_bare_name() {
577 let cx = cx_with(&["__Host-session=abc"]);
579 let cookie = cookies(&cx)
580 .override_prefix_host()
581 .get("session")
582 .expect("prefixed cookie should be found");
583
584 assert_eq!(cookie.name(), "session");
585 assert_eq!(cookie.value(), "abc");
586 }
587
588 #[test]
589 fn prefixed_cookies_round_trip_with_bare_and_prefixed_names() {
590 for (prefix, prefixed_name) in [
591 (Prefix::Host, "__Host-session"),
592 (Prefix::Secure, "__Secure-session"),
593 ] {
594 for conform in [Conform::Default, Conform::Override] {
595 for name in ["session", prefixed_name] {
596 let writer = cx_with(&[]);
597 Prefixed::new(cookies(&writer), prefix, conform).add((name, "abc"));
598 let set = set_cookies(&writer);
599 let echoed = pair(&set[0]);
600 assert_eq!(echoed, format!("{prefixed_name}=abc"));
601
602 let reader = cx_with(&[echoed]);
603 let jar = Prefixed::new(cookies(&reader), prefix, conform);
604 for lookup in ["session", prefixed_name] {
605 let cookie = jar.get(lookup).expect("cookie should round trip");
606 assert_eq!(cookie.name(), "session");
607 assert_eq!(cookie.value(), "abc");
608 }
609
610 jar.remove((name, ""));
611 assert!(jar.get(name).is_none());
612 let removed = set_cookies(&reader);
613 let removal = Cookie::parse(removed[0].as_str()).unwrap();
614 assert_eq!(removal.name(), prefixed_name);
615 assert_eq!(removal.max_age(), Some(time::Duration::ZERO));
616 }
617 }
618 }
619 }
620
621 #[test]
622 fn signed_cookie_round_trips() {
623 let key = Key::generate();
624
625 let writer = cx_with(&[]);
626 cookies(&writer).signed(&key).add(("user_id", "42"));
627 let echoed = pair(&set_cookies(&writer)[0]).to_owned();
628
629 let reader = cx_with(&[&echoed]);
630 let cookie = cookies(&reader)
631 .signed(&key)
632 .get("user_id")
633 .expect("valid signature should verify");
634 assert_eq!(cookie.value(), "42");
635 }
636
637 #[test]
638 fn signed_cookie_rejects_wrong_key_and_tampering() {
639 let key = Key::generate();
640
641 let writer = cx_with(&[]);
642 cookies(&writer).signed(&key).add(("user_id", "42"));
643 let echoed = pair(&set_cookies(&writer)[0]).to_owned();
644
645 let reader = cx_with(&[&echoed]);
647 assert!(
648 cookies(&reader)
649 .signed(&Key::generate())
650 .get("user_id")
651 .is_none()
652 );
653
654 let tampered = format!("{}x", &echoed[..echoed.len() - 1]);
656 let reader = cx_with(&[&tampered]);
657 assert!(cookies(&reader).signed(&key).get("user_id").is_none());
658 }
659
660 #[test]
661 fn signed_value_stays_readable() {
662 let key = Key::generate();
664 let cx = cx_with(&[]);
665 cookies(&cx).signed(&key).add(("user_id", "42"));
666
667 assert!(set_cookies(&cx)[0].contains("42"));
668 }
669
670 #[test]
671 fn private_cookie_round_trips_and_hides_value() {
672 let key = Key::generate();
673
674 let writer = cx_with(&[]);
675 cookies(&writer)
676 .private(&key)
677 .add(("session", "secret-token"));
678 let echoed = pair(&set_cookies(&writer)[0]).to_owned();
679
680 assert!(!echoed.contains("secret-token"), "{echoed}");
682
683 let reader = cx_with(&[&echoed]);
684 let cookie = cookies(&reader)
685 .private(&key)
686 .get("session")
687 .expect("correct key should decrypt");
688 assert_eq!(cookie.value(), "secret-token");
689 }
690
691 #[test]
692 fn private_cookie_rejects_wrong_key() {
693 let key = Key::generate();
694
695 let writer = cx_with(&[]);
696 cookies(&writer)
697 .private(&key)
698 .add(("session", "secret-token"));
699 let echoed = pair(&set_cookies(&writer)[0]).to_owned();
700
701 let reader = cx_with(&[&echoed]);
702 assert!(
703 cookies(&reader)
704 .private(&Key::generate())
705 .get("session")
706 .is_none()
707 );
708 }
709
710 #[test]
711 fn signed_cookies_helper_uses_app_context_key() {
712 let key = Key::generate();
713
714 let writer = cx_with_key(&[], key.clone());
715 signed_cookies(&writer).add(("user_id", "42"));
716 let echoed = pair(&set_cookies(&writer)[0]).to_owned();
717
718 let reader = cx_with_key(&[&echoed], key);
719 assert_eq!(
720 signed_cookies(&reader).get("user_id").unwrap().value(),
721 "42"
722 );
723 }
724
725 #[test]
726 fn composes_signing_with_prefix_and_defaults() {
727 let key = Key::generate();
730
731 let writer = cx_with(&[]);
732 cookies(&writer)
733 .signed(&key)
734 .override_prefix_host()
735 .default_same_site(SameSite::Lax)
736 .add(("session", "abc"));
737
738 let set = &set_cookies(&writer)[0];
739 assert!(set.starts_with("__Host-session="), "{set}");
740 assert!(set.contains("SameSite=Lax"), "{set}");
741 let echoed = pair(set).to_owned();
742
743 let reader = cx_with(&[&echoed]);
744 let cookie = cookies(&reader)
745 .signed(&key)
746 .override_prefix_host()
747 .get("session")
748 .expect("composed layers should round-trip");
749 assert_eq!(cookie.value(), "abc");
750 }
751}