use std::{
error::Error,
fmt,
str,
time::{Duration, SystemTime},
};
#[derive(Debug)]
pub struct ParseError(());
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid cookie string syntax")
}
}
impl Error for ParseError {}
#[derive(Clone, Debug)]
#[must_use = "builders have no effect if unused"]
pub struct CookieBuilder {
name: String,
value: String,
domain: Option<String>,
path: Option<String>,
secure: Option<bool>,
expiration: Option<SystemTime>,
}
impl CookieBuilder {
#[allow(unused)]
pub fn new<N, V>(name: N, value: V) -> Self
where
N: Into<String>,
V: Into<String>,
{
Self {
name: name.into(),
value: value.into(),
domain: None,
path: None,
secure: None,
expiration: None,
}
}
pub fn domain<S>(mut self, domain: S) -> Self
where
S: Into<String>,
{
self.domain = Some(domain.into());
self
}
pub fn path<S>(mut self, path: S) -> Self
where
S: Into<String>,
{
self.path = Some(path.into());
self
}
pub fn secure(mut self, secure: bool) -> Self {
self.secure = Some(secure);
self
}
pub fn expiration<T>(mut self, expiration: T) -> Self
where
T: Into<SystemTime>,
{
self.expiration = Some(expiration.into());
self
}
pub fn build(self) -> Result<Cookie, ParseError> {
let Self {
name,
value,
domain,
path,
secure,
expiration,
} = self;
let mut cookie = Cookie::new(name, value)?;
cookie.domain = domain;
cookie.path = path;
cookie.expiration = expiration;
if let Some(secure) = secure {
cookie.secure = secure;
}
Ok(cookie)
}
}
#[derive(Clone, Debug)]
pub struct Cookie {
name: String,
value: String,
domain: Option<String>,
path: Option<String>,
secure: bool,
expiration: Option<SystemTime>,
}
impl Cookie {
#[allow(unused)]
fn new<N, V>(name: N, value: V) -> Result<Self, ParseError>
where
N: Into<String>,
V: Into<String>,
{
let name = name.into();
let value = value.into();
if is_valid_token(name.as_bytes()) && is_valid_cookie_value(value.as_bytes()) {
Ok(Self {
name,
value,
domain: None,
path: None,
secure: false,
expiration: None,
})
} else {
Err(ParseError(()))
}
}
#[allow(unused)]
pub fn builder<N, V>(name: N, value: V) -> CookieBuilder
where
N: Into<String>,
V: Into<String>,
{
CookieBuilder::new(name, value)
}
pub(crate) fn parse<T>(header: T) -> Result<Self, ParseError>
where
T: AsRef<[u8]>,
{
Self::parse_impl(header.as_ref())
}
#[inline]
pub fn name(&self) -> &str {
&self.name
}
#[inline]
pub fn value(&self) -> &str {
&self.value
}
#[inline]
pub(crate) fn domain(&self) -> Option<&str> {
self.domain.as_deref()
}
#[inline]
pub(crate) fn path(&self) -> Option<&str> {
self.path.as_deref()
}
#[inline]
pub(crate) fn is_secure(&self) -> bool {
self.secure
}
#[inline]
#[allow(unused)]
pub(crate) fn is_persistent(&self) -> bool {
self.expiration.is_some()
}
pub(crate) fn is_expired(&self) -> bool {
if let Some(time) = self.expiration.as_ref() {
*time < SystemTime::now()
} else {
false
}
}
fn parse_impl(header: &[u8]) -> Result<Self, ParseError> {
let mut attributes = trim_left_ascii(header)
.split(|&byte| byte == b';')
.map(trim_left_ascii);
let first_pair = split_at_first(attributes.next().ok_or(ParseError(()))?, &b'=')
.ok_or(ParseError(()))?;
let cookie_name = parse_token(first_pair.0)?.into();
let cookie_value = parse_cookie_value(first_pair.1)?.into();
let mut cookie_domain = None;
let mut cookie_path = None;
let mut cookie_secure = false;
let mut cookie_expiration = None;
for attribute in attributes {
if let Some((name, value)) = split_at_first(attribute, &b'=') {
if name.eq_ignore_ascii_case(b"Expires") {
if cookie_expiration.is_none() {
if let Ok(value) = str::from_utf8(value) {
if let Ok(time) = httpdate::parse_http_date(value) {
cookie_expiration = Some(time);
}
}
}
} else if name.eq_ignore_ascii_case(b"Domain") {
if let Ok(value) = str::from_utf8(value) {
cookie_domain = Some(value.trim_start_matches('.').to_lowercase());
}
} else if name.eq_ignore_ascii_case(b"Max-Age") {
if let Ok(value) = str::from_utf8(value) {
if let Ok(seconds) = value.parse() {
cookie_expiration =
Some(SystemTime::now() + Duration::from_secs(seconds));
}
}
} else if name.eq_ignore_ascii_case(b"Path") {
if let Ok(value) = str::from_utf8(value) {
cookie_path = Some(value.to_owned());
}
}
} else if attribute.eq_ignore_ascii_case(b"Secure") {
cookie_secure = true;
}
}
Ok(Self {
name: cookie_name,
value: cookie_value,
secure: cookie_secure,
expiration: cookie_expiration,
domain: cookie_domain,
path: cookie_path,
})
}
}
impl PartialEq<&str> for Cookie {
fn eq(&self, other: &&str) -> bool {
self.value.as_str() == *other
}
}
impl PartialEq<String> for Cookie {
fn eq(&self, other: &String) -> bool {
self.value == *other
}
}
#[allow(unsafe_code)]
fn parse_cookie_value(mut bytes: &[u8]) -> Result<&str, ParseError> {
if bytes.starts_with(b"\"") && bytes.ends_with(b"\"") {
bytes = &bytes[1..bytes.len() - 1];
}
if !is_valid_cookie_value(bytes) {
return Err(ParseError(()));
}
Ok(unsafe { str::from_utf8_unchecked(bytes) })
}
fn is_valid_cookie_value(bytes: &[u8]) -> bool {
bytes.iter().all(|&byte| match byte {
0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E => true,
_ => false,
})
}
#[allow(unsafe_code)]
fn parse_token(bytes: &[u8]) -> Result<&str, ParseError> {
if is_valid_token(bytes) {
Ok(unsafe { str::from_utf8_unchecked(bytes) })
} else {
Err(ParseError(()))
}
}
fn is_valid_token(bytes: &[u8]) -> bool {
const SEPARATORS: &[u8] = b"()<>@,;:\\\"/[]?={} \t";
bytes
.iter()
.all(|byte| byte.is_ascii() && !byte.is_ascii_control() && !SEPARATORS.contains(byte))
}
fn trim_left_ascii(mut ascii: &[u8]) -> &[u8] {
while ascii.first() == Some(&b' ') {
ascii = &ascii[1..];
}
ascii
}
fn split_at_first<'a, T: PartialEq>(slice: &'a [T], separator: &T) -> Option<(&'a [T], &'a [T])> {
for (i, value) in slice.iter().enumerate() {
if value == separator {
return Some((&slice[..i], &slice[i + 1..]));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
fn system_time_timestamp(time: &SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test_case("foo")]
#[test_case("foo;=bar")]
#[test_case("bad_name@?=bar")]
#[test_case("bad_value_comma=bar,")]
#[test_case("bad_value_space= bar")]
fn parse_invalid(s: &str) {
assert!(Cookie::parse(s).is_err());
}
#[test_case("foo=bar")]
#[test_case(r#"foo="bar""#)]
fn parse_simple(s: &str) {
let cookie = Cookie::parse(s).unwrap();
assert_eq!(cookie.name(), "foo");
assert_eq!(cookie.value(), "bar");
assert_eq!(cookie.path(), None);
assert!(!cookie.is_secure());
assert!(!cookie.is_persistent());
}
#[test]
fn parse_persistent() {
let cookie = Cookie::parse("foo=bar; max-age=86400").unwrap();
assert_eq!(cookie.name(), "foo");
assert_eq!(cookie.value(), "bar");
assert_eq!(cookie.path(), None);
assert!(!cookie.is_secure());
assert!(cookie.is_persistent());
}
#[test]
fn parse_set_cookie_header_expires() {
let cookie = Cookie::parse(
"foo=bar; path=/sub;Secure; DOMAIN=baz.com;expires=Wed, 21 Oct 2015 07:28:00 GMT",
)
.unwrap();
assert_eq!(cookie.name(), "foo");
assert_eq!(cookie.value(), "bar");
assert_eq!(cookie.path(), Some("/sub"));
assert_eq!(cookie.domain.as_deref(), Some("baz.com"));
assert!(cookie.is_secure());
assert!(cookie.is_expired());
assert_eq!(
cookie.expiration.as_ref().map(system_time_timestamp),
Some(1_445_412_480)
);
}
#[test]
fn parse_set_cookie_header_max_age() {
let cookie =
Cookie::parse("foo=bar; path=/sub;Secure; DOMAIN=baz.com; max-age=60").unwrap();
assert_eq!(cookie.name(), "foo");
assert_eq!(cookie.value(), "bar");
assert_eq!(cookie.path(), Some("/sub"));
assert_eq!(cookie.domain.as_deref(), Some("baz.com"));
assert!(cookie.is_secure());
assert!(!cookie.is_expired());
assert!(
cookie
.expiration
.unwrap()
.duration_since(SystemTime::now())
.unwrap()
<= Duration::from_secs(60)
);
}
#[test]
fn create_cookie() {
let exp = SystemTime::now();
let cookie = Cookie::builder("foo", "bar")
.domain("baz.com")
.path("/sub")
.secure(true)
.expiration(exp)
.build()
.unwrap();
assert_eq!(cookie.name(), "foo");
assert_eq!(cookie.value(), "bar");
assert_eq!(cookie.path(), Some("/sub"));
assert_eq!(cookie.domain.as_deref(), Some("baz.com"));
assert!(cookie.is_secure());
assert_eq!(cookie.expiration, Some(exp));
}
}