use std::fmt;
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cookie {
pub name: String,
pub value: String,
pub max_age: Option<i64>,
pub path: String,
pub domain: Option<String>,
pub secure: bool,
pub httponly: bool,
pub samesite: SameSite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CookieError {
#[error("cookie header is empty")]
Empty,
#[error("cookie header must start with name=value")]
MissingNameValue,
#[error("cookie name cannot be empty")]
EmptyName,
#[error("invalid Max-Age value: {0}")]
InvalidMaxAge(String),
#[error("invalid SameSite value: {0}")]
InvalidSameSite(String),
#[error("missing value for cookie attribute: {0}")]
MissingAttributeValue(&'static str),
}
impl Cookie {
#[must_use]
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
max_age: None,
path: "/".to_string(),
domain: None,
secure: false,
httponly: false,
samesite: SameSite::Lax,
}
}
#[must_use]
pub fn to_header_value(&self) -> String {
let mut parts = vec![format!("{}={}", self.name, self.value)];
if let Some(max_age) = self.max_age {
parts.push(format!("Max-Age={max_age}"));
}
parts.push(format!("Path={}", self.path));
if let Some(domain) = &self.domain {
parts.push(format!("Domain={domain}"));
}
if self.secure {
parts.push("Secure".to_string());
}
if self.httponly {
parts.push("HttpOnly".to_string());
}
parts.push(format!("SameSite={}", self.samesite));
parts.join("; ")
}
pub fn parse(header: &str) -> Result<Self, CookieError> {
let mut parts = header.split(';').map(str::trim);
let first = parts.next().ok_or(CookieError::Empty)?;
if first.is_empty() {
return Err(CookieError::Empty);
}
let (name, value) = first.split_once('=').ok_or(CookieError::MissingNameValue)?;
if name.is_empty() {
return Err(CookieError::EmptyName);
}
let mut cookie = Self::new(name, value);
for attribute in parts {
if attribute.is_empty() {
continue;
}
let Some((key, raw_value)) = attribute
.split_once('=')
.map(|(key, value)| (key.trim(), value.trim()))
.or(Some((attribute, "")))
else {
continue;
};
match key.to_ascii_lowercase().as_str() {
"max-age" => {
if raw_value.is_empty() {
return Err(CookieError::MissingAttributeValue("Max-Age"));
}
cookie.max_age = Some(
raw_value
.parse()
.map_err(|_| CookieError::InvalidMaxAge(raw_value.to_string()))?,
);
}
"path" => {
if raw_value.is_empty() {
return Err(CookieError::MissingAttributeValue("Path"));
}
cookie.path = raw_value.to_string();
}
"domain" => {
if raw_value.is_empty() {
return Err(CookieError::MissingAttributeValue("Domain"));
}
cookie.domain = Some(raw_value.to_string());
}
"secure" => cookie.secure = true,
"httponly" => cookie.httponly = true,
"samesite" => {
if raw_value.is_empty() {
return Err(CookieError::MissingAttributeValue("SameSite"));
}
cookie.samesite = SameSite::from_str(raw_value)?;
}
_ => {}
}
}
Ok(cookie)
}
}
impl FromStr for SameSite {
type Err = CookieError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.eq_ignore_ascii_case("strict") {
Ok(Self::Strict)
} else if value.eq_ignore_ascii_case("lax") {
Ok(Self::Lax)
} else if value.eq_ignore_ascii_case("none") {
Ok(Self::None)
} else {
Err(CookieError::InvalidSameSite(value.to_string()))
}
}
}
impl fmt::Display for SameSite {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Strict => "Strict",
Self::Lax => "Lax",
Self::None => "None",
};
f.write_str(value)
}
}
#[cfg(test)]
mod tests {
use super::{Cookie, CookieError, SameSite};
#[test]
fn new_sets_expected_defaults() {
let cookie = Cookie::new("sessionid", "abc123");
assert_eq!(cookie.name, "sessionid");
assert_eq!(cookie.value, "abc123");
assert_eq!(cookie.max_age, None);
assert_eq!(cookie.path, "/");
assert_eq!(cookie.domain, None);
assert!(!cookie.secure);
assert!(!cookie.httponly);
assert_eq!(cookie.samesite, SameSite::Lax);
}
#[test]
fn to_header_value_serializes_all_configured_attributes() {
let mut cookie = Cookie::new("sessionid", "abc123");
cookie.max_age = Some(3600);
cookie.path = "/admin".to_string();
cookie.domain = Some("example.com".to_string());
cookie.secure = true;
cookie.httponly = true;
cookie.samesite = SameSite::Strict;
assert_eq!(
cookie.to_header_value(),
"sessionid=abc123; Max-Age=3600; Path=/admin; Domain=example.com; Secure; HttpOnly; SameSite=Strict"
);
}
#[test]
fn parse_reads_set_cookie_header() {
let cookie = Cookie::parse(
"sessionid=abc123; Max-Age=3600; Path=/admin; Domain=example.com; Secure; HttpOnly; SameSite=Strict",
)
.unwrap();
assert_eq!(cookie.name, "sessionid");
assert_eq!(cookie.value, "abc123");
assert_eq!(cookie.max_age, Some(3600));
assert_eq!(cookie.path, "/admin");
assert_eq!(cookie.domain.as_deref(), Some("example.com"));
assert!(cookie.secure);
assert!(cookie.httponly);
assert_eq!(cookie.samesite, SameSite::Strict);
}
#[test]
fn parse_rejects_invalid_values() {
assert_eq!(Cookie::parse(""), Err(CookieError::Empty));
assert_eq!(Cookie::parse("Secure"), Err(CookieError::MissingNameValue));
assert_eq!(
Cookie::parse("sessionid=abc123; Max-Age=forever"),
Err(CookieError::InvalidMaxAge("forever".to_string()))
);
assert_eq!(
Cookie::parse("sessionid=abc123; SameSite=sideways"),
Err(CookieError::InvalidSameSite("sideways".to_string()))
);
}
}