use std::borrow::Cow;
use std::fmt;
use rfc_6265::OffsetDateTime;
use rfc_6265::date::{format_imf_fixdate, parse_cookie_date, parse_imf_fixdate};
use crate::attributes::{CookieAttributes, Domain, Path};
use crate::cookie::Cookie;
use crate::encoding::{ValueEncoding, decode_cookie_value};
use crate::grammar::is_ws_char;
use crate::report::{PairIssue, Reported};
use crate::same_site::SameSite;
use crate::wire::split_checked_pair;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SetCookie<'a> {
cookie: Cookie<'a>,
attributes: CookieAttributes<'a>,
}
impl<'a> SetCookie<'a> {
pub fn from_parts(cookie: Cookie<'a>, attributes: CookieAttributes<'a>) -> Self {
Self { cookie, attributes }
}
pub fn new(name: &'a str, value: impl Into<Cow<'a, str>>) -> Self {
Self::from_parts(Cookie::new(name, value), CookieAttributes::default())
}
pub fn parse(header_value: &'a str) -> Option<Self> {
Self::parse_with(header_value, false, None).ok()
}
pub fn parse_strict(header_value: &'a str) -> Option<Self> {
Self::parse_with(header_value, true, None).ok()
}
pub fn try_parse(
header_value: &'a str,
) -> Result<Reported<Self, SetCookieIssue<'a>>, SetCookieIssue<'a>> {
let mut issues = Vec::new();
let value = Self::parse_with(header_value, false, Some(&mut issues))?;
Ok(Reported { value, issues })
}
pub fn try_parse_strict(
header_value: &'a str,
) -> Result<Reported<Self, SetCookieIssue<'a>>, SetCookieIssue<'a>> {
let mut issues = Vec::new();
let value = Self::parse_with(header_value, true, Some(&mut issues))?;
Ok(Reported { value, issues })
}
fn parse_with(
header_value: &'a str,
strict: bool,
mut report: Option<&mut Vec<SetCookieIssue<'a>>>,
) -> Result<Self, SetCookieIssue<'a>> {
let mut segments = header_value.split(';');
let (name, raw_value) = split_checked_pair(segments.next().unwrap_or_default().as_bytes())
.map_err(SetCookieIssue::InvalidPair)?;
let Some(value) = decode_cookie_value(raw_value, true) else {
#[cfg(feature = "tracing")]
tracing::debug!(
cookie = %name,
"rejecting Set-Cookie: value carries a byte outside the accepted \
set or percent-escapes that are not valid UTF-8"
);
return Err(SetCookieIssue::InvalidPair(PairIssue::InvalidValue {
name,
value: raw_value,
}));
};
let mut set_cookie =
Self::from_parts(Cookie::new(name, value), CookieAttributes::default());
let mut seen: u8 = 0;
for piece in segments {
let (attr, val) = match piece.split_once('=') {
Some((a, v)) => (a.trim_matches(is_ws_char), v.trim_matches(is_ws_char)),
None => (piece.trim_matches(is_ws_char), ""),
};
if attr.is_empty() {
continue; }
let Some(known) = KnownAttribute::recognize(attr) else {
let issue = SetCookieIssue::UnknownAttribute { name: attr };
if strict {
return Err(issue);
}
#[cfg(feature = "tracing")]
tracing::debug!(
attribute = %attr.escape_debug(),
"ignoring an unrecognised attribute; the cookie is kept (RFC 6265 §5.2)"
);
record(&mut report, issue);
continue;
};
if seen & known.bit() != 0 {
let issue = SetCookieIssue::DuplicateAttribute { attribute: known };
if strict {
return Err(issue);
}
#[cfg(feature = "tracing")]
tracing::debug!(
attribute = known.name(),
"duplicate attribute; the last occurrence that parses wins"
);
record(&mut report, issue);
}
seen |= known.bit();
let attributes = &mut set_cookie.attributes;
match known {
KnownAttribute::HttpOnly => {
if !val.is_empty() {
record(
&mut report,
SetCookieIssue::FlagWithValue {
attribute: known,
value: val,
},
);
}
attributes.http_only = true;
}
KnownAttribute::Secure => {
if !val.is_empty() {
record(
&mut report,
SetCookieIssue::FlagWithValue {
attribute: known,
value: val,
},
);
}
attributes.secure = true;
}
KnownAttribute::SameSite => {
if let Some(v) = noted(val.parse::<SameSite>().ok(), known, val, &mut report) {
attributes.same_site = Some(v);
}
}
KnownAttribute::Path => {
if let Some(v) = noted(Path::new(val), known, val, &mut report) {
attributes.path = Some(v);
}
}
KnownAttribute::Domain => {
if let Some(v) = noted(Domain::new(val), known, val, &mut report) {
attributes.domain = Some(v);
}
}
KnownAttribute::MaxAge => {
if let Some(v) = noted(val.parse::<u64>().ok(), known, val, &mut report) {
attributes.max_age = Some(v);
}
}
KnownAttribute::Expires => {
let parsed = if strict {
parse_imf_fixdate(val)
} else {
parse_cookie_date(val)
};
if let Some(v) = noted(parsed, known, val, &mut report) {
attributes.expires = Some(v);
}
}
}
}
Ok(set_cookie)
}
#[must_use]
pub fn with_encoding(mut self, encoding: ValueEncoding) -> Self {
self.cookie = self.cookie.with_encoding(encoding);
self
}
#[must_use]
pub fn with_attributes(mut self, attributes: CookieAttributes<'a>) -> Self {
self.attributes = attributes;
self
}
#[must_use]
pub fn http_only(mut self) -> Self {
self.attributes.http_only = true;
self
}
#[must_use]
pub fn secure(mut self) -> Self {
self.attributes.secure = true;
self
}
#[must_use]
pub fn same_site(mut self, same_site: SameSite) -> Self {
self.attributes.same_site = Some(same_site);
self
}
#[must_use]
pub fn path(mut self, path: &'a str) -> Self {
self.attributes.path = Path::new(path);
self
}
#[must_use]
pub fn domain(mut self, domain: &'a str) -> Self {
self.attributes.domain = Domain::new(domain);
self
}
#[must_use]
pub fn max_age(mut self, seconds: u64) -> Self {
self.attributes.max_age = Some(seconds);
self
}
#[must_use]
pub fn expires(mut self, when: OffsetDateTime) -> Self {
self.attributes.expires = Some(when);
self
}
pub fn name(&self) -> &str {
self.cookie.name()
}
pub fn value(&self) -> &str {
self.cookie.value()
}
pub fn encoding(&self) -> ValueEncoding {
self.cookie.encoding()
}
pub fn cookie(&self) -> &Cookie<'a> {
&self.cookie
}
pub fn attributes(&self) -> &CookieAttributes<'a> {
&self.attributes
}
pub fn into_cookie(self) -> Cookie<'a> {
self.cookie
}
pub fn into_attributes(self) -> CookieAttributes<'a> {
self.attributes
}
pub fn to_request_pair(&self) -> String {
self.cookie.to_request_pair()
}
pub fn to_set_cookie(&self) -> String {
let attributes = self.set_cookie_attributes();
std::iter::once(self.cookie.to_request_pair())
.chain(attributes.iter().map(|attribute| attribute.to_string()))
.collect::<Vec<_>>()
.join("; ")
}
fn set_cookie_attributes(&self) -> Vec<SetCookieAttribute<'a>> {
let a = &self.attributes;
[
a.http_only.then_some(SetCookieAttribute::HttpOnly),
a.same_site.map(SetCookieAttribute::SameSite),
a.secure.then_some(SetCookieAttribute::Secure),
a.path.map(|p| SetCookieAttribute::Path(p.as_str())),
a.domain.map(|d| SetCookieAttribute::Domain(d.as_str())),
a.expires.map(SetCookieAttribute::Expires),
a.max_age.map(SetCookieAttribute::MaxAge),
]
.into_iter()
.flatten()
.collect()
}
}
impl<'a> From<(Cookie<'a>, CookieAttributes<'a>)> for SetCookie<'a> {
fn from((cookie, attributes): (Cookie<'a>, CookieAttributes<'a>)) -> Self {
SetCookie::from_parts(cookie, attributes)
}
}
mod attr_name {
pub const HTTP_ONLY: &str = "HttpOnly";
pub const SECURE: &str = "Secure";
pub const SAME_SITE: &str = "SameSite";
pub const PATH: &str = "Path";
pub const DOMAIN: &str = "Domain";
pub const MAX_AGE: &str = "Max-Age";
pub const EXPIRES: &str = "Expires";
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum KnownAttribute {
HttpOnly,
Secure,
SameSite,
Path,
Domain,
MaxAge,
Expires,
}
impl KnownAttribute {
const ALL: [Self; 7] = [
Self::HttpOnly,
Self::Secure,
Self::SameSite,
Self::Path,
Self::Domain,
Self::MaxAge,
Self::Expires,
];
pub const fn name(self) -> &'static str {
match self {
Self::HttpOnly => attr_name::HTTP_ONLY,
Self::Secure => attr_name::SECURE,
Self::SameSite => attr_name::SAME_SITE,
Self::Path => attr_name::PATH,
Self::Domain => attr_name::DOMAIN,
Self::MaxAge => attr_name::MAX_AGE,
Self::Expires => attr_name::EXPIRES,
}
}
fn recognize(attr: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|known| attr.eq_ignore_ascii_case(known.name()))
}
const fn bit(self) -> u8 {
1 << (self as u8)
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SetCookieIssue<'a> {
InvalidPair(
PairIssue<'a>,
),
#[non_exhaustive]
UnknownAttribute {
name: &'a str,
},
#[non_exhaustive]
DuplicateAttribute {
attribute: KnownAttribute,
},
#[non_exhaustive]
InvalidAttributeValue {
attribute: KnownAttribute,
value: &'a str,
},
#[non_exhaustive]
FlagWithValue {
attribute: KnownAttribute,
value: &'a str,
},
}
impl fmt::Display for SetCookieIssue<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPair(issue) => write!(f, "Set-Cookie {issue}"),
Self::UnknownAttribute { name } => {
write!(f, "unrecognised attribute `{}`", name.escape_debug())
}
Self::DuplicateAttribute { attribute } => {
write!(f, "duplicate `{}` attribute", attribute.name())
}
Self::InvalidAttributeValue { attribute, value } => {
write!(
f,
"malformed `{}` value `{}` (attribute dropped, cookie kept)",
attribute.name(),
value.escape_debug()
)
}
Self::FlagWithValue { attribute, value } => {
write!(
f,
"value `{}` on the presence-only `{}` flag (flag set, value discarded)",
value.escape_debug(),
attribute.name()
)
}
}
}
}
impl std::error::Error for SetCookieIssue<'_> {}
fn record<'a>(report: &mut Option<&mut Vec<SetCookieIssue<'a>>>, issue: SetCookieIssue<'a>) {
if let Some(sink) = report.as_deref_mut() {
sink.push(issue);
}
}
fn noted<'a, T>(
parsed: Option<T>,
attribute: KnownAttribute,
raw_value: &'a str,
report: &mut Option<&mut Vec<SetCookieIssue<'a>>>,
) -> Option<T> {
if parsed.is_none() {
#[cfg(feature = "tracing")]
tracing::debug!(
attribute = attribute.name(),
value = %raw_value.escape_debug(),
"dropping a malformed known attribute; the cookie is kept"
);
record(
report,
SetCookieIssue::InvalidAttributeValue {
attribute,
value: raw_value,
},
);
}
parsed
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum SetCookieAttribute<'a> {
HttpOnly,
SameSite(SameSite),
Secure,
Path(&'a str),
Domain(&'a str),
Expires(OffsetDateTime),
MaxAge(u64),
}
impl fmt::Display for SetCookieAttribute<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::HttpOnly => f.write_str(attr_name::HTTP_ONLY),
Self::SameSite(same_site) => {
write!(f, "{}={}", attr_name::SAME_SITE, same_site.as_str())
}
Self::Secure => f.write_str(attr_name::SECURE),
Self::Path(path) => write!(f, "{}={}", attr_name::PATH, path),
Self::Domain(domain) => write!(f, "{}={}", attr_name::DOMAIN, domain),
Self::Expires(when) => {
write!(f, "{}={}", attr_name::EXPIRES, format_imf_fixdate(when))
}
Self::MaxAge(seconds) => write!(f, "{}={}", attr_name::MAX_AGE, seconds),
}
}
}
impl TryFrom<SetCookie<'_>> for http::HeaderValue {
type Error = http::header::InvalidHeaderValue;
fn try_from(cookie: SetCookie<'_>) -> Result<Self, Self::Error> {
http::HeaderValue::try_from(cookie.to_set_cookie())
}
}
impl TryFrom<&SetCookie<'_>> for http::HeaderValue {
type Error = http::header::InvalidHeaderValue;
fn try_from(cookie: &SetCookie<'_>) -> Result<Self, Self::Error> {
http::HeaderValue::try_from(cookie.to_set_cookie())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_attribute_bits_are_distinct_and_recognition_is_case_insensitive() {
let mut mask = 0u8;
for known in KnownAttribute::ALL {
assert_eq!(mask & known.bit(), 0, "{known:?} bit collides");
mask |= known.bit();
assert_eq!(KnownAttribute::recognize(known.name()), Some(known));
assert_eq!(
KnownAttribute::recognize(&known.name().to_ascii_uppercase()),
Some(known)
);
assert_eq!(
KnownAttribute::recognize(&known.name().to_ascii_lowercase()),
Some(known)
);
}
assert_eq!(KnownAttribute::recognize("Partitioned"), None);
assert_eq!(KnownAttribute::recognize(""), None);
}
#[test]
fn strict_rejects_a_duplicate_of_every_attribute() {
for (known, dup) in [
(KnownAttribute::HttpOnly, "HttpOnly; HttpOnly"),
(KnownAttribute::Secure, "Secure; Secure"),
(KnownAttribute::SameSite, "SameSite=Lax; SameSite=Strict"),
(KnownAttribute::Path, "Path=/a; Path=/b"),
(KnownAttribute::Domain, "Domain=a.test; Domain=b.test"),
(KnownAttribute::MaxAge, "Max-Age=1; Max-Age=2"),
(
KnownAttribute::Expires,
"Expires=Sun, 06 Nov 1994 08:49:37 GMT; Expires=Mon, 07 Nov 1994 08:49:37 GMT",
),
] {
let header = format!("n=v; {dup}");
assert!(
SetCookie::parse_strict(&header).is_none(),
"strict must reject the duplicated {:?} in {header:?}",
known.name()
);
assert!(
SetCookie::parse(&header).is_some(),
"lenient must keep the cookie for {header:?}"
);
}
assert_eq!(KnownAttribute::ALL.len(), 7);
}
#[test]
fn builder_attribute_order_is_fixed() {
assert_eq!(SetCookie::new("n", "v").to_set_cookie(), "n=v");
assert_eq!(
SetCookie::new("n", "v").http_only().to_set_cookie(),
"n=v; HttpOnly"
);
assert_eq!(
SetCookie::new("n", "v")
.max_age(60)
.domain("example.test")
.path("/app")
.secure()
.same_site(SameSite::None)
.http_only()
.to_set_cookie(),
"n=v; HttpOnly; SameSite=None; Secure; Path=/app; Domain=example.test; Max-Age=60"
);
assert_eq!(
SetCookie::new("n", "v")
.same_site(SameSite::Lax)
.to_set_cookie(),
"n=v; SameSite=Lax"
);
}
#[test]
fn builder_max_age_is_u64_without_saturation() {
assert!(
SetCookie::new("n", "v")
.max_age(u64::MAX)
.to_set_cookie()
.ends_with("; Max-Age=18446744073709551615")
);
assert!(
SetCookie::new("n", "v")
.max_age(0)
.to_set_cookie()
.ends_with("; Max-Age=0")
);
}
#[test]
fn hardened_session_cookie_shape() {
let c = SetCookie::new("SID", "deadbeef")
.with_encoding(ValueEncoding::Percent)
.http_only()
.same_site(SameSite::Strict)
.secure()
.path("/")
.max_age(3600)
.to_set_cookie();
assert_eq!(
c,
"SID=deadbeef; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=3600"
);
}
#[test]
fn with_attributes_applies_a_prebuilt_set() {
let hardened = CookieAttributes::default()
.http_only()
.secure()
.same_site(SameSite::Strict)
.path("/")
.max_age(3600);
let c = Cookie::new("SID", "deadbeef")
.with_encoding(ValueEncoding::Percent)
.with_attributes(hardened);
assert_eq!(
c.to_set_cookie(),
"SID=deadbeef; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=3600"
);
let parts: SetCookie<'_> =
(Cookie::new("n", "v"), CookieAttributes::default().secure()).into();
assert_eq!(parts.to_set_cookie(), "n=v; Secure");
}
#[test]
fn set_cookie_attributes_are_typed_and_in_canonical_order() {
let c = SetCookie::new("n", "v")
.max_age(60)
.domain("example.test")
.path("/app")
.secure()
.same_site(SameSite::Lax)
.http_only();
assert_eq!(
c.set_cookie_attributes(),
vec![
SetCookieAttribute::HttpOnly,
SetCookieAttribute::SameSite(SameSite::Lax),
SetCookieAttribute::Secure,
SetCookieAttribute::Path("/app"),
SetCookieAttribute::Domain("example.test"),
SetCookieAttribute::MaxAge(60),
]
);
assert!(SetCookie::new("n", "v").set_cookie_attributes().is_empty());
}
#[test]
fn set_cookie_attribute_renders_without_a_leading_separator() {
assert_eq!(SetCookieAttribute::HttpOnly.to_string(), "HttpOnly");
assert_eq!(SetCookieAttribute::Secure.to_string(), "Secure");
assert_eq!(
SetCookieAttribute::SameSite(SameSite::Strict).to_string(),
"SameSite=Strict"
);
assert_eq!(SetCookieAttribute::Path("/").to_string(), "Path=/");
assert_eq!(
SetCookieAttribute::Domain("a.test").to_string(),
"Domain=a.test"
);
assert_eq!(SetCookieAttribute::MaxAge(0).to_string(), "Max-Age=0");
}
#[test]
fn accessors_delegate_to_the_kernel_and_flags_are_bool() {
let c = SetCookie::new("SID", "deadbeef").with_encoding(ValueEncoding::Percent);
assert_eq!(c.name(), "SID");
assert_eq!(c.value(), "deadbeef");
assert_eq!(c.encoding(), ValueEncoding::Percent);
assert!(!c.attributes().http_only);
assert!(!c.attributes().secure);
}
#[test]
fn cookie_and_into_cookie_recover_the_kernel() {
let sc = SetCookie::new("n", "v").path("/x").secure();
assert_eq!(sc.cookie().name(), "n");
assert_eq!(sc.cookie().to_request_pair(), "n=v");
assert!(sc.attributes().secure);
assert_eq!(sc.attributes().path.map(|v| v.as_str()), Some("/x"));
assert_eq!(sc.into_cookie().to_request_pair(), "n=v");
}
#[test]
fn into_attributes_takes_the_attribute_set() {
let attrs = SetCookie::new("n", "v")
.secure()
.max_age(60)
.into_attributes();
assert!(attrs.secure);
assert_eq!(attrs.max_age, Some(60));
}
#[test]
fn try_into_header_value_is_byte_pinned() {
let hv = http::HeaderValue::try_from(
SetCookie::new("SID", "deadbeef")
.with_encoding(ValueEncoding::Percent)
.http_only()
.same_site(SameSite::Strict)
.secure()
.path("/")
.max_age(3600),
)
.unwrap();
assert_eq!(
hv.to_str().unwrap(),
"SID=deadbeef; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=3600"
);
}
#[test]
fn try_into_header_value_matches_to_set_cookie() {
let c = SetCookie::new("n", "v").http_only().max_age(60);
let via_ref = http::HeaderValue::try_from(&c).unwrap();
let via_owned = http::HeaderValue::try_from(c.clone()).unwrap();
assert_eq!(via_ref.to_str().unwrap(), c.to_set_cookie());
assert_eq!(via_owned, via_ref);
}
#[test]
fn try_into_header_value_rejects_raw_injection() {
let c = SetCookie::new("n", "x\r\nSet-Cookie: evil=1").with_encoding(ValueEncoding::Raw);
assert!(http::HeaderValue::try_from(c).is_err());
}
#[test]
fn raw_lets_non_ascii_through_construction_but_not_as_text() {
let raw = http::HeaderValue::try_from(
SetCookie::new("n", "café").with_encoding(ValueEncoding::Raw),
)
.expect("obs-text bytes are valid at header construction");
assert!(
raw.to_str().is_err(),
"the header carries raw non-ASCII bytes, not visible-ASCII text"
);
let managed = http::HeaderValue::try_from(
SetCookie::new("n", "café").with_encoding(ValueEncoding::Percent),
)
.unwrap();
assert_eq!(managed.to_str().unwrap(), "n=caf%C3%A9");
}
#[test]
fn try_into_header_value_managed_never_errors() {
let hostile = [
"a;b",
"a\r\nX: y",
"a b",
"café",
"a,b",
"a\"b",
"a\\b",
"\u{0}\u{1f}\u{7f}",
"%41",
];
for v in hostile {
for enc in [
ValueEncoding::Auto,
ValueEncoding::Percent,
ValueEncoding::Quoted,
] {
let c = SetCookie::new("n", v).with_encoding(enc);
let hv = http::HeaderValue::try_from(&c)
.unwrap_or_else(|e| panic!("managed {enc:?} of {v:?} must form a header: {e}"));
assert_eq!(hv.to_str().unwrap(), c.to_set_cookie());
}
}
}
#[test]
fn parse_round_trips_a_built_set_cookie() {
let wire = SetCookie::new("SID", "deadbeef")
.with_encoding(ValueEncoding::Percent)
.http_only()
.same_site(SameSite::Strict)
.secure()
.path("/")
.max_age(3600)
.to_set_cookie();
let parsed = SetCookie::parse(&wire).unwrap();
assert_eq!(parsed.name(), "SID");
assert_eq!(parsed.value(), "deadbeef");
assert!(parsed.attributes().http_only && parsed.attributes().secure);
assert_eq!(parsed.attributes().same_site, Some(SameSite::Strict));
assert_eq!(parsed.attributes().path.map(|v| v.as_str()), Some("/"));
assert_eq!(parsed.attributes().max_age, Some(3600));
assert_eq!(parsed.attributes().domain, None);
assert_eq!(parsed.to_set_cookie(), wire);
}
#[test]
fn parse_decodes_value_like_the_request_reader() {
assert_eq!(SetCookie::parse("pref=caf%C3%A9").unwrap().value(), "café");
assert_eq!(SetCookie::parse(r#"pref="a b""#).unwrap().value(), "a b");
}
#[test]
fn parse_attributes_are_case_insensitive() {
let p =
SetCookie::parse("n=v; SECURE; httponly; samesite=lax; PATH=/x; max-age=60").unwrap();
assert!(p.attributes().secure && p.attributes().http_only);
assert_eq!(p.attributes().same_site, Some(SameSite::Lax));
assert_eq!(p.attributes().path.map(|v| v.as_str()), Some("/x"));
assert_eq!(p.attributes().max_age, Some(60));
}
#[test]
fn parse_strict_rejects_unknown_default_ignores() {
assert!(SetCookie::parse_strict("SID=x; Priority=High; Max-Age=60").is_none());
let p = SetCookie::parse("SID=x; Priority=High; Max-Age=60").unwrap();
assert_eq!(p.value(), "x");
assert_eq!(p.attributes().max_age, Some(60));
}
#[test]
fn parse_reads_expires_as_a_date() {
use time::macros::datetime;
let p =
SetCookie::parse("SID=x; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=60").unwrap();
assert_eq!(p.value(), "x");
assert_eq!(
p.attributes().expires,
Some(datetime!(2021-06-09 10:18:14 UTC))
);
assert_eq!(p.attributes().max_age, Some(60));
let bad = SetCookie::parse("SID=x; Expires=not-a-date").unwrap();
assert_eq!(bad.attributes().expires, None);
assert_eq!(bad.value(), "x");
}
#[test]
fn parse_strict_tolerates_empty_and_trailing_semicolons() {
assert_eq!(SetCookie::parse("SID=x;").unwrap().value(), "x");
let p = SetCookie::parse("SID=x; ; Secure").unwrap();
assert_eq!(p.value(), "x");
assert!(p.attributes().secure);
}
#[test]
fn parse_skips_malformed_attributes_but_keeps_the_cookie() {
let p = SetCookie::parse("SID=x; Max-Age=banana; SameSite=Bogus; HttpOnly").unwrap();
assert!(p.attributes().http_only);
assert_eq!(p.attributes().max_age, None); assert_eq!(p.attributes().same_site, None); assert_eq!(p.value(), "x"); }
#[test]
fn parse_max_age_u64_and_negative() {
assert_eq!(
SetCookie::parse("n=v; Max-Age=18446744073709551615")
.unwrap()
.attributes()
.max_age,
Some(u64::MAX)
);
assert_eq!(
SetCookie::parse("n=v; Max-Age=-1")
.unwrap()
.attributes()
.max_age,
None
);
}
#[test]
fn parse_rejects_no_equals_and_bad_name() {
assert!(SetCookie::parse("HttpOnly").is_none()); assert!(SetCookie::parse("na me=v; Secure").is_none()); assert!(SetCookie::parse("").is_none());
assert!(SetCookie::parse("=v").is_none()); }
#[test]
fn parse_splits_first_semicolon_then_first_equals() {
let p = SetCookie::parse("a=b=c; Path=/x").unwrap();
assert_eq!(p.name(), "a");
assert_eq!(p.value(), "b=c"); assert_eq!(p.attributes().path.map(|v| v.as_str()), Some("/x"));
}
#[test]
fn try_parse_agrees_with_parse_and_renders_identically() {
for header in [
"SID=x; HttpOnly; Secure; Path=/; Max-Age=60",
"SID=x; Max-Age=banana; SameSite=Bogus; HttpOnly",
"n=v; Priority=High; Partitioned",
"n=v; Path=/a; Path=/b",
"n=v; Secure=1",
"n=v; Expires=not-a-date",
"HttpOnly",
"na me=v; Secure",
"",
"=v",
] {
let plain = SetCookie::parse(header);
let reported = SetCookie::try_parse(header);
assert_eq!(
plain.is_some(),
reported.is_ok(),
"lenient fatality on {header:?}"
);
if let (Some(plain), Ok(reported)) = (plain, reported) {
assert_eq!(plain, reported.value, "lenient cookie on {header:?}");
assert_eq!(
plain.to_set_cookie(),
reported.value.to_set_cookie(),
"lenient rendering on {header:?}"
);
}
let plain = SetCookie::parse_strict(header);
let reported = SetCookie::try_parse_strict(header);
assert_eq!(
plain.is_some(),
reported.is_ok(),
"strict fatality on {header:?}"
);
if let (Some(plain), Ok(reported)) = (plain, reported) {
assert_eq!(plain, reported.value, "strict cookie on {header:?}");
}
}
}
#[test]
fn mistyped_or_fused_attribute_is_reported_not_silent() {
let reported = SetCookie::try_parse("SID=x; Secure; HttpOnlyy").unwrap();
assert!(reported.value.attributes().secure);
assert!(!reported.value.attributes().http_only); assert_eq!(
reported.issues,
vec![SetCookieIssue::UnknownAttribute { name: "HttpOnlyy" }] );
let reported = SetCookie::try_parse("SID=x; Secure HttpOnly").unwrap();
assert!(!reported.value.attributes().secure);
assert!(!reported.value.attributes().http_only);
assert_eq!(
reported.issues,
vec![SetCookieIssue::UnknownAttribute {
name: "Secure HttpOnly"
}]
);
assert_eq!(
SetCookie::try_parse_strict("SID=x; HttpOnlyy"),
Err(SetCookieIssue::UnknownAttribute { name: "HttpOnlyy" })
);
}
#[test]
fn malformed_known_values_are_reported_in_both_modes() {
for (header, attribute, value) in [
("n=v; Max-Age=banana", KnownAttribute::MaxAge, "banana"),
("n=v; SameSite=Bogus", KnownAttribute::SameSite, "Bogus"),
("n=v; Expires=nonsense", KnownAttribute::Expires, "nonsense"),
("n=v; Path=a\u{1}b", KnownAttribute::Path, "a\u{1}b"),
] {
let expected = vec![SetCookieIssue::InvalidAttributeValue { attribute, value }];
let lenient = SetCookie::try_parse(header).unwrap();
assert_eq!(lenient.issues, expected, "lenient {header:?}");
let strict = SetCookie::try_parse_strict(header).unwrap();
assert_eq!(strict.issues, expected, "strict {header:?}");
assert!(!strict.is_clean());
}
let rfc850 = "n=v; Expires=Sunday, 06-Nov-94 08:49:37 GMT";
assert!(SetCookie::try_parse(rfc850).unwrap().is_clean());
let strict = SetCookie::try_parse_strict(rfc850).unwrap();
assert_eq!(
strict.issues,
vec![SetCookieIssue::InvalidAttributeValue {
attribute: KnownAttribute::Expires,
value: "Sunday, 06-Nov-94 08:49:37 GMT"
}]
);
}
#[test]
fn duplicates_and_valued_flags_are_reported() {
let reported = SetCookie::try_parse("n=v; Path=/a; Path=/b; Secure=1").unwrap();
assert_eq!(
reported.issues,
vec![
SetCookieIssue::DuplicateAttribute {
attribute: KnownAttribute::Path
},
SetCookieIssue::FlagWithValue {
attribute: KnownAttribute::Secure,
value: "1"
},
],
"issues arrive in wire order"
);
assert_eq!(
reported.value.attributes().path.map(|p| p.as_str()),
Some("/b")
);
assert!(reported.value.attributes().secure);
assert_eq!(
SetCookie::try_parse_strict("n=v; Path=/a; Path=/b"),
Err(SetCookieIssue::DuplicateAttribute {
attribute: KnownAttribute::Path
})
);
}
#[test]
fn fatal_pair_issues_carry_the_pair_defect() {
assert_eq!(
SetCookie::try_parse("HttpOnly"),
Err(SetCookieIssue::InvalidPair(PairIssue::MissingEquals {
segment: b"HttpOnly"
}))
);
assert_eq!(
SetCookie::try_parse("na me=v; Secure"),
Err(SetCookieIssue::InvalidPair(PairIssue::InvalidName {
name: b"na me"
}))
);
assert_eq!(
SetCookie::try_parse("n=a\u{1}b; Secure"),
Err(SetCookieIssue::InvalidPair(PairIssue::InvalidValue {
name: "n",
value: b"a\x01b"
}))
);
}
#[test]
fn parse_keeps_earlier_valid_attribute_over_later_malformed() {
let p = SetCookie::parse("n=v; Max-Age=60; Max-Age=banana").unwrap();
assert_eq!(p.attributes().max_age, Some(60));
let p = SetCookie::parse("n=v; Domain=valid.example.com; Domain=café").unwrap();
assert_eq!(
p.attributes().domain.map(|d| d.as_str()),
Some("valid.example.com")
);
let p = SetCookie::parse("n=v; Max-Age=1; Max-Age=2").unwrap();
assert_eq!(p.attributes().max_age, Some(2));
let p = SetCookie::parse("n=v; Max-Age=banana").unwrap();
assert_eq!(p.attributes().max_age, None);
let reported = SetCookie::try_parse("n=v; Max-Age=60; Max-Age=banana").unwrap();
assert_eq!(
reported.issues,
vec![
SetCookieIssue::DuplicateAttribute {
attribute: KnownAttribute::MaxAge
},
SetCookieIssue::InvalidAttributeValue {
attribute: KnownAttribute::MaxAge,
value: "banana"
},
]
);
assert_eq!(reported.value.attributes().max_age, Some(60));
}
#[test]
fn issue_display_never_echoes_wire_dangerous_bytes() {
let issues = [
SetCookieIssue::InvalidPair(PairIssue::InvalidValue {
name: "n",
value: b"a;b\r\n\x00",
}),
SetCookieIssue::UnknownAttribute {
name: "Http\u{1}Only; evil",
},
SetCookieIssue::InvalidAttributeValue {
attribute: KnownAttribute::Expires,
value: "a\r\nSet-Cookie: evil=1",
},
SetCookieIssue::FlagWithValue {
attribute: KnownAttribute::Secure,
value: "x\u{0}y",
},
];
for issue in issues {
let rendered = issue.to_string();
for byte in [b'\r', b'\n', b'\0'] {
assert!(
!rendered.bytes().any(|b| b == byte),
"{rendered:?} echoes {byte:#04x}"
);
}
}
}
}