mod jar;
mod macros;
mod map;
mod prefix;
mod private;
#[cfg(feature = "router")]
mod router;
mod signed;
mod store;
pub use jar::*;
pub use map::*;
pub use prefix::*;
pub use private::*;
#[cfg(feature = "router")]
pub use router::*;
pub use signed::*;
pub use store::*;
use prefix::Conform;
use std::sync::OnceLock;
pub use cookie::{Cookie, Expiration, Key, SameSite, time};
use topcoat_core::context::{Cx, app_context, request_context};
pub trait Cookies {
fn get(&self, name: &str) -> Option<Cookie<'static>>;
fn add<C: Into<Cookie<'static>>>(&self, cookie: C);
fn remove<C: Into<Cookie<'static>>>(&self, cookie: C);
fn signed(self, key: &Key) -> SignedJar<'_, Self>
where
Self: Sized,
{
SignedJar::new(self, key)
}
fn private(self, key: &Key) -> PrivateJar<'_, Self>
where
Self: Sized,
{
PrivateJar::new(self, key)
}
fn map<F>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: Fn(&mut Cookie<'static>),
{
Map::new(self, f)
}
fn override_prefix_host(self) -> Prefixed<Self>
where
Self: Sized,
{
Prefixed::new(self, Prefix::Host, Conform::Override)
}
fn default_prefix_host(self) -> Prefixed<Self>
where
Self: Sized,
{
Prefixed::new(self, Prefix::Host, Conform::Default)
}
fn override_prefix_secure(self) -> Prefixed<Self>
where
Self: Sized,
{
Prefixed::new(self, Prefix::Secure, Conform::Override)
}
fn default_prefix_secure(self) -> Prefixed<Self>
where
Self: Sized,
{
Prefixed::new(self, Prefix::Secure, Conform::Default)
}
fn override_secure(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| cookie.set_secure(value))
}
fn default_secure(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| {
if cookie.secure().is_none() {
cookie.set_secure(value);
}
})
}
fn override_http_only(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| cookie.set_http_only(value))
}
fn default_http_only(self, value: bool) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| {
if cookie.http_only().is_none() {
cookie.set_http_only(value);
}
})
}
fn override_same_site(self, value: SameSite) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| cookie.set_same_site(value))
}
fn default_same_site(self, value: SameSite) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| {
if cookie.same_site().is_none() {
cookie.set_same_site(value);
}
})
}
fn override_path(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
let value = value.into();
self.map(move |cookie| cookie.set_path(value.clone()))
}
fn default_path(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
let value = value.into();
self.map(move |cookie| {
if cookie.path().is_none() {
cookie.set_path(value.clone());
}
})
}
fn override_domain(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
let value = value.into();
self.map(move |cookie| cookie.set_domain(value.clone()))
}
fn default_domain(self, value: impl Into<String>) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
let value = value.into();
self.map(move |cookie| {
if cookie.domain().is_none() {
cookie.set_domain(value.clone());
}
})
}
fn override_max_age(self, value: time::Duration) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| cookie.set_max_age(value))
}
fn default_max_age(self, value: time::Duration) -> Map<Self, impl Fn(&mut Cookie<'static>)>
where
Self: Sized,
{
self.map(move |cookie| {
if cookie.max_age().is_none() {
cookie.set_max_age(value);
}
})
}
}
#[derive(Debug, Default)]
pub struct CookieJarCell {
jar: OnceLock<CookieJar>,
}
impl CookieJarCell {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn get_or_init(&self, cx: &Cx) -> &CookieJar {
self.jar.get_or_init(|| CookieJar::from_request(cx))
}
fn get(&self) -> Option<&CookieJar> {
self.jar.get()
}
}
#[must_use]
pub fn cookies(cx: &Cx) -> &CookieJar {
request_context::<CookieJarCell>(cx).get_or_init(cx)
}
#[must_use]
pub fn signed_cookies(cx: &Cx) -> SignedJar<'_, &CookieJar> {
cookies(cx).signed(app_context::<Key>(cx))
}
#[must_use]
pub fn private_cookies(cx: &Cx) -> PrivateJar<'_, &CookieJar> {
cookies(cx).private(app_context::<Key>(cx))
}
#[doc(hidden)]
pub fn write_cookies(cx: &Cx, headers: &mut http::HeaderMap) {
let Some(jar) = request_context::<CookieJarCell>(cx).get() else {
return;
};
for value in jar.delta_headers() {
headers.append(http::header::SET_COOKIE, value);
}
}
#[cfg(test)]
mod tests {
use http::{HeaderMap, Request, header};
use topcoat_core::context::CxTestBuilder;
use super::*;
fn cx_with(cookie_headers: &[&str]) -> Cx {
let mut builder = Request::builder();
for value in cookie_headers {
builder = builder.header(header::COOKIE, *value);
}
let (parts, ()) = builder.body(()).unwrap().into_parts();
CxTestBuilder::new()
.request_context(parts)
.request_context(CookieJarCell::new())
.build()
}
fn cx_with_key(cookie_headers: &[&str], key: Key) -> Cx {
let mut builder = Request::builder();
for value in cookie_headers {
builder = builder.header(header::COOKIE, *value);
}
let (parts, ()) = builder.body(()).unwrap().into_parts();
CxTestBuilder::new()
.app_context(key)
.request_context(parts)
.request_context(CookieJarCell::new())
.build()
}
fn set_cookies(cx: &Cx) -> Vec<String> {
let mut headers = HeaderMap::new();
write_cookies(cx, &mut headers);
headers
.get_all(header::SET_COOKIE)
.iter()
.map(|value| value.to_str().unwrap().to_owned())
.collect()
}
fn pair(set_cookie: &str) -> &str {
set_cookie.split(';').next().unwrap()
}
#[test]
fn reads_incoming_cookies() {
let cx = cx_with(&["theme=dark; lang=en"]);
let jar = cookies(&cx);
assert_eq!(jar.get("theme").unwrap().value(), "dark");
assert_eq!(jar.get("lang").unwrap().value(), "en");
assert!(jar.get("missing").is_none());
}
#[test]
fn reads_cookies_across_multiple_headers() {
let cx = cx_with(&["theme=dark", "lang=en"]);
let jar = cookies(&cx);
assert_eq!(jar.get("theme").unwrap().value(), "dark");
assert_eq!(jar.get("lang").unwrap().value(), "en");
}
#[test]
fn reading_does_not_produce_a_delta() {
let cx = cx_with(&["theme=dark"]);
let _ = cookies(&cx).get("theme");
assert!(set_cookies(&cx).is_empty());
}
#[test]
fn add_emits_set_cookie() {
let cx = cx_with(&[]);
cookies(&cx).add(("theme", "dark"));
let set = set_cookies(&cx);
assert_eq!(set.len(), 1);
assert_eq!(pair(&set[0]), "theme=dark");
}
#[test]
fn remove_emits_expiring_cookie() {
let cx = cx_with(&["session=abc123"]);
cookies(&cx).remove(("session", ""));
let set = set_cookies(&cx);
assert_eq!(set.len(), 1);
assert!(set[0].starts_with("session="));
assert!(set[0].contains("Max-Age=0"), "{}", set[0]);
}
#[test]
fn remove_replays_path_default_so_browser_matches() {
let cx = cx_with(&["session=abc123"]);
cookies(&cx).default_path("/app").remove(("session", ""));
let set = &set_cookies(&cx)[0];
assert!(set.contains("Path=/app"), "{set}");
assert!(set.contains("Max-Age=0"), "{set}");
}
#[test]
fn remove_keeps_expiry_despite_max_age_default() {
let cx = cx_with(&["session=abc123"]);
cookies(&cx)
.default_max_age(time::Duration::hours(1))
.remove(("session", ""));
assert!(
set_cookies(&cx)[0].contains("Max-Age=0"),
"{:?}",
set_cookies(&cx)
);
}
#[test]
fn write_cookies_skips_when_jar_untouched() {
let cx = CxTestBuilder::new()
.request_context(CookieJarCell::new())
.build();
let mut headers = HeaderMap::new();
write_cookies(&cx, &mut headers);
assert!(headers.get(header::SET_COOKIE).is_none());
}
#[test]
fn default_attribute_only_fills_when_unset() {
let cx = cx_with(&[]);
cookies(&cx)
.default_secure(true)
.add(Cookie::build(("a", "b")).secure(false).build());
assert!(!set_cookies(&cx)[0].contains("Secure"));
}
#[test]
fn override_attribute_replaces_existing() {
let cx = cx_with(&[]);
cookies(&cx)
.override_secure(true)
.add(Cookie::build(("a", "b")).secure(false).build());
assert!(set_cookies(&cx)[0].contains("Secure"));
}
#[test]
fn host_prefix_applies_name_and_attributes() {
let cx = cx_with(&[]);
cookies(&cx).override_prefix_host().add(("session", "abc"));
let set = &set_cookies(&cx)[0];
assert!(set.starts_with("__Host-session=abc"), "{set}");
assert!(set.contains("Secure"), "{set}");
assert!(set.contains("Path=/"), "{set}");
}
#[test]
fn host_prefix_reads_back_under_bare_name() {
let cx = cx_with(&["__Host-session=abc"]);
let cookie = cookies(&cx)
.override_prefix_host()
.get("session")
.expect("prefixed cookie should be found");
assert_eq!(cookie.name(), "session");
assert_eq!(cookie.value(), "abc");
}
#[test]
fn signed_cookie_round_trips() {
let key = Key::generate();
let writer = cx_with(&[]);
cookies(&writer).signed(&key).add(("user_id", "42"));
let echoed = pair(&set_cookies(&writer)[0]).to_owned();
let reader = cx_with(&[&echoed]);
let cookie = cookies(&reader)
.signed(&key)
.get("user_id")
.expect("valid signature should verify");
assert_eq!(cookie.value(), "42");
}
#[test]
fn signed_cookie_rejects_wrong_key_and_tampering() {
let key = Key::generate();
let writer = cx_with(&[]);
cookies(&writer).signed(&key).add(("user_id", "42"));
let echoed = pair(&set_cookies(&writer)[0]).to_owned();
let reader = cx_with(&[&echoed]);
assert!(
cookies(&reader)
.signed(&Key::generate())
.get("user_id")
.is_none()
);
let tampered = format!("{}x", &echoed[..echoed.len() - 1]);
let reader = cx_with(&[&tampered]);
assert!(cookies(&reader).signed(&key).get("user_id").is_none());
}
#[test]
fn signed_value_stays_readable() {
let key = Key::generate();
let cx = cx_with(&[]);
cookies(&cx).signed(&key).add(("user_id", "42"));
assert!(set_cookies(&cx)[0].contains("42"));
}
#[test]
fn private_cookie_round_trips_and_hides_value() {
let key = Key::generate();
let writer = cx_with(&[]);
cookies(&writer)
.private(&key)
.add(("session", "secret-token"));
let echoed = pair(&set_cookies(&writer)[0]).to_owned();
assert!(!echoed.contains("secret-token"), "{echoed}");
let reader = cx_with(&[&echoed]);
let cookie = cookies(&reader)
.private(&key)
.get("session")
.expect("correct key should decrypt");
assert_eq!(cookie.value(), "secret-token");
}
#[test]
fn private_cookie_rejects_wrong_key() {
let key = Key::generate();
let writer = cx_with(&[]);
cookies(&writer)
.private(&key)
.add(("session", "secret-token"));
let echoed = pair(&set_cookies(&writer)[0]).to_owned();
let reader = cx_with(&[&echoed]);
assert!(
cookies(&reader)
.private(&Key::generate())
.get("session")
.is_none()
);
}
#[test]
fn signed_cookies_helper_uses_app_context_key() {
let key = Key::generate();
let writer = cx_with_key(&[], key.clone());
signed_cookies(&writer).add(("user_id", "42"));
let echoed = pair(&set_cookies(&writer)[0]).to_owned();
let reader = cx_with_key(&[&echoed], key);
assert_eq!(
signed_cookies(&reader).get("user_id").unwrap().value(),
"42"
);
}
#[test]
fn composes_signing_with_prefix_and_defaults() {
let key = Key::generate();
let writer = cx_with(&[]);
cookies(&writer)
.signed(&key)
.override_prefix_host()
.default_same_site(SameSite::Lax)
.add(("session", "abc"));
let set = &set_cookies(&writer)[0];
assert!(set.starts_with("__Host-session="), "{set}");
assert!(set.contains("SameSite=Lax"), "{set}");
let echoed = pair(set).to_owned();
let reader = cx_with(&[&echoed]);
let cookie = cookies(&reader)
.signed(&key)
.override_prefix_host()
.get("session")
.expect("composed layers should round-trip");
assert_eq!(cookie.value(), "abc");
}
}