Skip to main content

topcoat_cookie/
lib.rs

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
30/// A request-scoped cookie jar.
31///
32/// Read and write cookies through the root [`CookieJar`] or a configured
33/// adapter. Adapter methods return a jar that applies their configuration to
34/// subsequent operations.
35///
36/// Bring this trait into scope to use the combinators.
37pub trait Cookies {
38    /// Returns the cookie named `name`, or `None` if it is absent (or, for
39    /// signed/encrypted jars, fails to verify).
40    fn get(&self, name: &str) -> Option<Cookie<'static>>;
41
42    /// Adds `cookie`. It is serialized into a `Set-Cookie` response header once
43    /// the cookie router layer handles the response.
44    ///
45    /// # Panics
46    ///
47    /// Panics if the response headers have already been written, which is the
48    /// case in work that outlives the handler, such as a streaming body or a
49    /// WebSocket task.
50    fn add<C: Into<Cookie<'static>>>(&self, cookie: C);
51
52    /// Removes `cookie`. If the request carried an original cookie with the
53    /// same name, an expiring removal cookie is sent. Pass the same `Path`/
54    /// `Domain` the cookie was set with.
55    ///
56    /// # Panics
57    ///
58    /// Panics if the response headers have already been written, which is the
59    /// case in work that outlives the handler, such as a streaming body or a
60    /// WebSocket task.
61    fn remove<C: Into<Cookie<'static>>>(&self, cookie: C);
62
63    /// Wraps this jar so signed cookies are written and verified with `key`.
64    fn signed(self, key: &Key) -> SignedJar<'_, Self>
65    where
66        Self: Sized,
67    {
68        SignedJar::new(self, key)
69    }
70
71    /// Wraps this jar so cookies are encrypted and decrypted with `key`.
72    fn private(self, key: &Key) -> PrivateJar<'_, Self>
73    where
74        Self: Sized,
75    {
76        PrivateJar::new(self, key)
77    }
78
79    /// Wraps this jar so every added cookie is passed through `f` before being
80    /// stored. Use this for custom attribute changes.
81    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    /// Forces the `__Host-` prefix and its required attributes (`Secure`,
90    /// `Path=/`, no `Domain`) on every added cookie.
91    fn override_prefix_host(self) -> Prefixed<Self>
92    where
93        Self: Sized,
94    {
95        Prefixed::new(self, Prefix::Host, Conform::Override)
96    }
97
98    /// Applies the `__Host-` prefix, filling its required attributes only when
99    /// the cookie does not already set them.
100    fn default_prefix_host(self) -> Prefixed<Self>
101    where
102        Self: Sized,
103    {
104        Prefixed::new(self, Prefix::Host, Conform::Default)
105    }
106
107    /// Forces the `__Secure-` prefix and its required `Secure` attribute on
108    /// every added cookie.
109    fn override_prefix_secure(self) -> Prefixed<Self>
110    where
111        Self: Sized,
112    {
113        Prefixed::new(self, Prefix::Secure, Conform::Override)
114    }
115
116    /// Applies the `__Secure-` prefix, setting `Secure` only when the cookie
117    /// does not already set it.
118    fn default_prefix_secure(self) -> Prefixed<Self>
119    where
120        Self: Sized,
121    {
122        Prefixed::new(self, Prefix::Secure, Conform::Default)
123    }
124
125    /// Sets `Secure` on every added cookie, overriding any existing value.
126    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    /// Sets `Secure` on added cookies that do not already specify it.
134    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    /// Sets `HttpOnly` on every added cookie, overriding any existing value.
146    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    /// Sets `HttpOnly` on added cookies that do not already specify it.
154    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    /// Sets `SameSite` on every added cookie, overriding any existing value.
166    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    /// Sets `SameSite` on added cookies that do not already specify it.
174    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    /// Sets `Path` on every added cookie, overriding any existing value.
186    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    /// Sets `Path` on added cookies that do not already specify it.
195    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    /// Sets `Domain` on every added cookie, overriding any existing value.
208    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    /// Sets `Domain` on added cookies that do not already specify it.
217    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    /// Sets `Max-Age` on every added cookie, overriding any existing value.
230    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    /// Sets `Max-Age` on added cookies that do not already specify it.
238    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/// Request-context storage for the lazily built cookie jar.
251///
252/// The cookie router layer inserts one cell per request. The first call to
253/// [`cookies`] parses the incoming `Cookie` headers into a [`CookieJar`] and
254/// stores it here; response finalization reads the same cell to emit pending
255/// `Set-Cookie` headers only if the jar was actually touched.
256///
257/// Finalization also seals the cell, so a jar handed out afterwards, whether it
258/// was built during the request or on first access from a task that outlives
259/// the handler, rejects writes.
260#[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        // A jar built after the cell was sealed inherits the seal. Checking
275        // here rather than inside the initializer also closes the window where
276        // the seal lands while another thread is still building the jar.
277        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    /// Closes the request's jar for writing, now and for any jar built later.
288    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/// Returns the request's root [`CookieJar`], parsing the incoming `Cookie`
297/// header on first access and memoizing it for the rest of the request.
298///
299/// Use the [`Cookies`] combinators to layer signing, encryption, prefixes, or
300/// default attributes on top.
301///
302/// # Panics
303///
304/// Panics if the cookie router layer has not been installed for this request.
305#[must_use]
306#[track_caller]
307pub fn cookies(cx: &Cx) -> &CookieJar {
308    request_context::<CookieJarCell>(cx).get_or_init(cx)
309}
310
311/// Returns the root jar wrapped in a [`SignedJar`], using the [`Key`]
312/// registered as app context.
313///
314/// # Panics
315///
316/// Panics if no [`Key`] was registered as app context.
317#[must_use]
318#[track_caller]
319pub fn signed_cookies(cx: &Cx) -> SignedJar<'_, &CookieJar> {
320    cookies(cx).signed(app_context::<Key>(cx))
321}
322
323/// Returns the root jar wrapped in a [`PrivateJar`], using the [`Key`]
324/// registered as app context.
325///
326/// # Panics
327///
328/// Panics if no [`Key`] was registered as app context.
329#[must_use]
330#[track_caller]
331pub fn private_cookies(cx: &Cx) -> PrivateJar<'_, &CookieJar> {
332    cookies(cx).private(app_context::<Key>(cx))
333}
334
335/// Appends the request's pending cookie changes to `headers` as `Set-Cookie`
336/// entries.
337///
338/// Called by the router after each handler runs. If no cookie helper was used
339/// during the request, the jar was never built, so we skip without parsing the
340/// incoming `Cookie` header at all.
341///
342/// The jar is sealed either way: from here on the response is out of the
343/// framework's hands, so a write would go nowhere and panics instead.
344#[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    /// Builds a `Cx` whose request carries the given `Cookie` header values (one
363    /// per entry, so multi-header parsing can be exercised). No `Parts` are
364    /// registered when `cookie_headers` is empty *and* `with_parts` is false.
365    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    /// Like [`cx_with`], but also registers `key` as app context so the
379    /// `signed_cookies`/`private_cookies` helpers can find it.
380    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    /// The `Set-Cookie` header values the request would emit.
395    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    /// The leading `name=value` pair of a `Set-Cookie` value, i.e. what the
406    /// browser would echo back in a `Cookie` header.
407    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        // Cookies that arrived on the request are "original" and must not be
433        // echoed back as Set-Cookie just because they were read.
434        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        // A cookie written with `default_path` must be removed with the same
464        // Path, or the browser won't match and clear it.
465        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        // A `default_max_age` must not leak onto a removal: the jar still expires
476        // it with Max-Age=0.
477        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        // The jar is never accessed, so nothing should be written. Crucially,
492        // we register no `Parts`: if `write_cookies` parsed the request anyway
493        // it would panic looking them up, proving it short-circuits.
494        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        // Work that outlives the handler, like a streaming body, may well need
506        // to look at the request's cookies.
507        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        // The handler never touched cookies, so this is the first call that
536        // builds the jar. It must still come back sealed.
537        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        // Already has an explicit `Secure=false`, so the default is ignored.
547        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        // The browser sends the prefixed name; the adapter strips it on read.
578        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        // A different key cannot verify the signature.
646        let reader = cx_with(&[&echoed]);
647        assert!(
648            cookies(&reader)
649                .signed(&Key::generate())
650                .get("user_id")
651                .is_none()
652        );
653
654        // Neither can the correct key once the value is altered.
655        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        // Signing authenticates but does not hide the value.
663        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        // Encryption hides the plaintext from the wire.
681        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        // Layers stack in any order: the cookie is signed, prefixed, and gets a
728        // default SameSite, and still verifies on read.
729        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}