use crate::date::HttpDate;
use core::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CookieError {
Empty,
InvalidFormat,
InvalidName,
InvalidValue,
InvalidAttribute,
InvalidSameSite,
}
impl fmt::Display for CookieError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CookieError::Empty => write!(f, "empty cookie"),
CookieError::InvalidFormat => write!(f, "invalid cookie format"),
CookieError::InvalidName => write!(f, "invalid cookie name"),
CookieError::InvalidValue => write!(f, "invalid cookie value"),
CookieError::InvalidAttribute => write!(f, "invalid cookie attribute"),
CookieError::InvalidSameSite => write!(f, "invalid SameSite attribute"),
}
}
}
impl std::error::Error for CookieError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cookie {
name: String,
value: String,
}
impl Cookie {
pub fn parse(input: &str) -> Result<Vec<Cookie>, CookieError> {
let input = input.trim();
if input.is_empty() {
return Err(CookieError::Empty);
}
let mut cookies = Vec::new();
for pair in input.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let (name, value) = parse_cookie_pair(pair)?;
cookies.push(Cookie {
name: name.to_string(),
value: value.to_string(),
});
}
if cookies.is_empty() {
return Err(CookieError::Empty);
}
Ok(cookies)
}
pub fn new(name: &str, value: &str) -> Result<Self, CookieError> {
if name.is_empty() || !is_valid_cookie_name(name) {
return Err(CookieError::InvalidName);
}
if !is_valid_cookie_value(value) {
return Err(CookieError::InvalidValue);
}
Ok(Cookie {
name: name.to_string(),
value: value.to_string(),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn value(&self) -> &str {
&self.value
}
}
impl fmt::Display for Cookie {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}={}", self.name, self.value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SameSite {
Strict,
#[default]
Lax,
None,
}
impl SameSite {
fn from_str(s: &str) -> Result<Self, CookieError> {
match s.to_ascii_lowercase().as_str() {
"strict" => Ok(SameSite::Strict),
"lax" => Ok(SameSite::Lax),
"none" => Ok(SameSite::None),
_ => Err(CookieError::InvalidSameSite),
}
}
}
impl fmt::Display for SameSite {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SameSite::Strict => write!(f, "Strict"),
SameSite::Lax => write!(f, "Lax"),
SameSite::None => write!(f, "None"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetCookie {
name: String,
value: String,
expires: Option<HttpDate>,
max_age: Option<i64>,
domain: Option<String>,
path: Option<String>,
secure: bool,
http_only: bool,
same_site: Option<SameSite>,
}
impl SetCookie {
pub fn parse(input: &str) -> Result<Self, CookieError> {
let input = input.trim();
if input.is_empty() {
return Err(CookieError::Empty);
}
let mut parts = input.split(';');
let first = parts.next().ok_or(CookieError::InvalidFormat)?;
let (name, value) = parse_cookie_pair(first.trim())?;
let mut set_cookie = SetCookie {
name: name.to_string(),
value: value.to_string(),
expires: None,
max_age: None,
domain: None,
path: None,
secure: false,
http_only: false,
same_site: None,
};
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(eq_pos) = part.find('=') {
let attr_name = part[..eq_pos].trim();
let attr_value = part[eq_pos + 1..].trim();
match attr_name.to_ascii_lowercase().as_str() {
"expires" => {
if let Ok(date) = HttpDate::parse(attr_value) {
set_cookie.expires = Some(date);
}
}
"max-age" => {
let bytes = attr_value.as_bytes();
if let Some(&first) = bytes.first()
&& (first.is_ascii_digit() || first == b'-')
&& bytes[1..].iter().all(|b| b.is_ascii_digit())
&& let Ok(age) = attr_value.parse::<i64>()
{
set_cookie.max_age = Some(age);
}
}
"domain" => {
let d = attr_value.strip_prefix('.').unwrap_or(attr_value);
if d.is_empty() {
} else {
set_cookie.domain = Some(d.to_ascii_lowercase());
}
}
"path" => {
set_cookie.path = Some(attr_value.to_string());
}
"samesite" => {
set_cookie.same_site = Some(SameSite::from_str(attr_value)?);
}
_ => {
}
}
} else {
match part.to_ascii_lowercase().as_str() {
"secure" => set_cookie.secure = true,
"httponly" => set_cookie.http_only = true,
_ => {
}
}
}
}
Ok(set_cookie)
}
pub fn new(name: &str, value: &str) -> Result<Self, CookieError> {
if name.is_empty() || !is_valid_cookie_name(name) {
return Err(CookieError::InvalidName);
}
if !is_valid_cookie_value(value) {
return Err(CookieError::InvalidValue);
}
Ok(SetCookie {
name: name.to_string(),
value: value.to_string(),
expires: None,
max_age: None,
domain: None,
path: None,
secure: false,
http_only: false,
same_site: None,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn value(&self) -> &str {
&self.value
}
pub fn expires(&self) -> Option<&HttpDate> {
self.expires.as_ref()
}
pub fn max_age(&self) -> Option<i64> {
self.max_age
}
pub fn domain(&self) -> Option<&str> {
self.domain.as_deref()
}
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
pub fn secure(&self) -> bool {
self.secure
}
pub fn http_only(&self) -> bool {
self.http_only
}
pub fn same_site(&self) -> Option<SameSite> {
self.same_site
}
pub fn with_expires(mut self, expires: HttpDate) -> Self {
self.expires = Some(expires);
self
}
pub fn with_max_age(mut self, max_age: i64) -> Self {
self.max_age = Some(max_age);
self
}
pub fn with_domain(mut self, domain: &str) -> Self {
self.domain = Some(domain.to_string());
self
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
pub fn with_secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
pub fn with_http_only(mut self, http_only: bool) -> Self {
self.http_only = http_only;
self
}
pub fn with_same_site(mut self, same_site: SameSite) -> Self {
self.same_site = Some(same_site);
self
}
}
impl fmt::Display for SetCookie {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}={}", self.name, self.value)?;
if let Some(expires) = &self.expires {
write!(f, "; Expires={}", expires)?;
}
if let Some(max_age) = self.max_age {
write!(f, "; Max-Age={}", max_age)?;
}
if let Some(domain) = &self.domain {
write!(f, "; Domain={}", domain)?;
}
if let Some(path) = &self.path {
write!(f, "; Path={}", path)?;
}
if self.secure {
write!(f, "; Secure")?;
}
if self.http_only {
write!(f, "; HttpOnly")?;
}
if let Some(same_site) = self.same_site {
write!(f, "; SameSite={}", same_site)?;
}
Ok(())
}
}
fn parse_cookie_pair(pair: &str) -> Result<(&str, &str), CookieError> {
let eq_pos = pair.find('=').ok_or(CookieError::InvalidFormat)?;
let name = pair[..eq_pos].trim();
let value = pair[eq_pos + 1..].trim();
if name.is_empty() {
return Err(CookieError::InvalidName);
}
if !is_valid_cookie_name(name) {
return Err(CookieError::InvalidName);
}
let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
&value[1..value.len() - 1]
} else {
value
};
if !is_valid_cookie_value(value) {
return Err(CookieError::InvalidValue);
}
Ok((name, value))
}
fn is_valid_cookie_name(s: &str) -> bool {
!s.is_empty() && s.bytes().all(is_token_char)
}
fn is_valid_cookie_value(s: &str) -> bool {
s.bytes().all(is_cookie_octet)
}
fn is_token_char(b: u8) -> bool {
matches!(b,
b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' |
b'0'..=b'9' | b'A'..=b'Z' | b'^' | b'_' | b'`' | b'a'..=b'z' | b'|' | b'~'
)
}
fn is_cookie_octet(b: u8) -> bool {
b == 0x21
|| (0x23..=0x2B).contains(&b)
|| (0x2D..=0x3A).contains(&b)
|| (0x3C..=0x5B).contains(&b)
|| (0x5D..=0x7E).contains(&b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cookie_parse_single() {
let cookies = Cookie::parse("session=abc123").unwrap();
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name(), "session");
assert_eq!(cookies[0].value(), "abc123");
}
#[test]
fn test_cookie_parse_multiple() {
let cookies = Cookie::parse("session=abc123; user=john").unwrap();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].name(), "session");
assert_eq!(cookies[0].value(), "abc123");
assert_eq!(cookies[1].name(), "user");
assert_eq!(cookies[1].value(), "john");
}
#[test]
fn test_cookie_parse_with_spaces() {
let cookies = Cookie::parse(" session = abc123 ; user = john ").unwrap();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].name(), "session");
assert_eq!(cookies[0].value(), "abc123");
}
#[test]
fn test_cookie_parse_empty() {
assert!(Cookie::parse("").is_err());
}
#[test]
fn test_cookie_display() {
let cookie = Cookie::new("session", "abc123").unwrap();
assert_eq!(cookie.to_string(), "session=abc123");
}
#[test]
fn test_set_cookie_parse_simple() {
let cookie = SetCookie::parse("session=abc123").unwrap();
assert_eq!(cookie.name(), "session");
assert_eq!(cookie.value(), "abc123");
assert!(!cookie.secure());
assert!(!cookie.http_only());
}
#[test]
fn test_set_cookie_parse_with_attributes() {
let cookie = SetCookie::parse("session=abc123; Path=/; HttpOnly; Secure").unwrap();
assert_eq!(cookie.name(), "session");
assert_eq!(cookie.value(), "abc123");
assert_eq!(cookie.path(), Some("/"));
assert!(cookie.http_only());
assert!(cookie.secure());
}
#[test]
fn test_set_cookie_parse_with_domain() {
let cookie = SetCookie::parse("session=abc123; Domain=example.com").unwrap();
assert_eq!(cookie.domain(), Some("example.com"));
}
#[test]
fn test_set_cookie_parse_with_max_age() {
let cookie = SetCookie::parse("session=abc123; Max-Age=3600").unwrap();
assert_eq!(cookie.max_age(), Some(3600));
}
#[test]
fn test_set_cookie_parse_with_expires() {
let cookie =
SetCookie::parse("session=abc123; Expires=Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
assert!(cookie.expires().is_some());
}
#[test]
fn test_set_cookie_parse_with_samesite() {
let cookie = SetCookie::parse("session=abc123; SameSite=Strict").unwrap();
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
let cookie = SetCookie::parse("session=abc123; SameSite=Lax").unwrap();
assert_eq!(cookie.same_site(), Some(SameSite::Lax));
let cookie = SetCookie::parse("session=abc123; SameSite=None").unwrap();
assert_eq!(cookie.same_site(), Some(SameSite::None));
}
#[test]
fn test_set_cookie_display() {
let cookie = SetCookie::new("session", "abc123")
.unwrap()
.with_path("/")
.with_secure(true)
.with_http_only(true);
let s = cookie.to_string();
assert!(s.contains("session=abc123"));
assert!(s.contains("Path=/"));
assert!(s.contains("Secure"));
assert!(s.contains("HttpOnly"));
}
#[test]
fn test_set_cookie_builder() {
let cookie = SetCookie::new("session", "abc123")
.unwrap()
.with_domain("example.com")
.with_path("/app")
.with_max_age(3600)
.with_secure(true)
.with_http_only(true)
.with_same_site(SameSite::Strict);
assert_eq!(cookie.name(), "session");
assert_eq!(cookie.value(), "abc123");
assert_eq!(cookie.domain(), Some("example.com"));
assert_eq!(cookie.path(), Some("/app"));
assert_eq!(cookie.max_age(), Some(3600));
assert!(cookie.secure());
assert!(cookie.http_only());
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
}
#[test]
fn test_cookie_parse_quoted_value() {
let cookies = Cookie::parse("session=\"abc123\"").unwrap();
assert_eq!(cookies[0].value(), "abc123");
}
#[test]
fn test_same_site_default() {
assert_eq!(SameSite::default(), SameSite::Lax);
}
#[test]
fn test_set_cookie_invalid_expires_ignored() {
let cookie = SetCookie::parse("session=abc123; Expires=invalid-date").unwrap();
assert_eq!(cookie.name(), "session");
assert_eq!(cookie.value(), "abc123");
assert!(cookie.expires().is_none());
}
#[test]
fn test_set_cookie_invalid_max_age_ignored() {
let cookie = SetCookie::parse("session=abc123; Max-Age=not-a-number").unwrap();
assert_eq!(cookie.name(), "session");
assert_eq!(cookie.value(), "abc123");
assert!(cookie.max_age().is_none());
}
}