use std::fmt::Write as _;
#[derive(Debug, Clone)]
pub struct CsrfCookieConfig {
pub name: String,
pub path: String,
pub domain: Option<String>,
pub same_site: SameSite,
pub secure: bool,
pub http_only: bool,
pub max_age_seconds: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
fn as_str(self) -> &'static str {
match self {
SameSite::Strict => "Strict",
SameSite::Lax => "Lax",
SameSite::None => "None",
}
}
}
impl CsrfCookieConfig {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
path: "/".into(),
domain: None,
same_site: SameSite::Lax,
secure: true,
http_only: true,
max_age_seconds: 600,
}
}
pub fn for_apple(name: impl Into<String>) -> Self {
Self {
same_site: SameSite::None,
..Self::new(name)
}
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = path.into();
self
}
pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
self.domain = Some(domain.into());
self
}
pub fn with_same_site(mut self, ss: SameSite) -> Self {
self.same_site = ss;
self
}
pub fn with_secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
pub fn with_max_age_seconds(mut self, seconds: i64) -> Self {
self.max_age_seconds = seconds;
self
}
pub fn set_cookie(&self, value: &str) -> String {
let mut s = String::with_capacity(128);
write!(&mut s, "{}={}; Path={}", self.name, value, self.path).unwrap();
if let Some(d) = &self.domain {
write!(&mut s, "; Domain={d}").unwrap();
}
write!(&mut s, "; Max-Age={}", self.max_age_seconds).unwrap();
write!(&mut s, "; SameSite={}", self.same_site.as_str()).unwrap();
if self.http_only {
s.push_str("; HttpOnly");
}
if self.secure {
s.push_str("; Secure");
}
s
}
pub fn clear_cookie(&self) -> String {
let mut s = String::with_capacity(128);
write!(&mut s, "{}=; Path={}", self.name, self.path).unwrap();
if let Some(d) = &self.domain {
write!(&mut s, "; Domain={d}").unwrap();
}
s.push_str("; Max-Age=0");
write!(&mut s, "; SameSite={}", self.same_site.as_str()).unwrap();
if self.http_only {
s.push_str("; HttpOnly");
}
if self.secure {
s.push_str("; Secure");
}
s
}
}
pub fn read_cookie<'a>(header_value: &'a str, name: &str) -> Option<&'a str> {
for raw in header_value.split(';') {
let raw = raw.trim();
let Some((k, v)) = raw.split_once('=') else {
continue;
};
if k == name {
return Some(v);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_cookie_defaults() {
let cfg = CsrfCookieConfig::new("x");
let out = cfg.set_cookie("abc");
assert!(out.contains("x=abc"));
assert!(out.contains("Path=/"));
assert!(out.contains("Max-Age=600"));
assert!(out.contains("SameSite=Lax"));
assert!(out.contains("HttpOnly"));
assert!(out.contains("Secure"));
assert!(!out.contains("Domain="));
}
#[test]
fn set_cookie_apple_defaults_to_samesite_none() {
let cfg = CsrfCookieConfig::for_apple("ap");
let out = cfg.set_cookie("xyz");
assert!(out.contains("SameSite=None"));
assert!(out.contains("Secure"));
}
#[test]
fn set_cookie_can_drop_secure_and_set_domain() {
let cfg = CsrfCookieConfig::new("y")
.with_secure(false)
.with_domain("example.com")
.with_max_age_seconds(60);
let out = cfg.set_cookie("v");
assert!(!out.contains("Secure"));
assert!(out.contains("Domain=example.com"));
assert!(out.contains("Max-Age=60"));
}
#[test]
fn clear_cookie_zero_max_age() {
let cfg = CsrfCookieConfig::new("z");
let out = cfg.clear_cookie();
assert!(out.contains("z="));
assert!(out.contains("Max-Age=0"));
}
#[test]
fn read_cookie_picks_named_value() {
let h = "foo=bar; cheers_csrf_google=ABC123; baz=qux";
assert_eq!(read_cookie(h, "cheers_csrf_google"), Some("ABC123"));
assert_eq!(read_cookie(h, "foo"), Some("bar"));
assert_eq!(read_cookie(h, "missing"), None);
}
#[test]
fn read_cookie_tolerates_whitespace() {
let h = " a=1 ; b=2";
assert_eq!(read_cookie(h, "a"), Some("1"));
assert_eq!(read_cookie(h, "b"), Some("2"));
}
#[test]
fn read_cookie_empty_header_returns_none() {
assert_eq!(read_cookie("", "a"), None);
}
}