use std::borrow::Cow;
use std::fmt;
use rfc_6265::OffsetDateTime;
use rfc_6265::date::{ImfFixdate, parse_cookie_date, parse_imf_fixdate};
use rfc_6265::grammar::{has_host_prefix, has_secure_prefix};
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,
) -> Result<Reported<Self, SetCookieIssue<'a>>, PairIssue<'a>> {
let mut issues = Vec::new();
let value = Self::parse_with(header_value, false, &mut issues)?;
Ok(Reported { value, issues })
}
pub fn parse_strict(
header_value: &'a str,
) -> Result<Reported<Self, SetCookieIssue<'a>>, PairIssue<'a>> {
let mut issues = Vec::new();
let value = Self::parse_with(header_value, true, &mut issues)?;
Ok(Reported { value, issues })
}
fn parse_with(
header_value: &'a str,
strict: bool,
report: &mut Vec<SetCookieIssue<'a>>,
) -> Result<Self, PairIssue<'a>> {
let mut segments = header_value.split(';');
let (name, raw_value) = split_checked_pair(segments.next().unwrap_or_default().as_bytes())?;
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(PairIssue::InvalidValue {
name,
value: raw_value,
});
};
let mut set_cookie =
Self::from_parts(Cookie::new(name, value), CookieAttributes::default());
let mut seen: u16 = 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() && val.is_empty() {
continue; }
let Some(known) = KnownAttribute::recognize(attr) else {
#[cfg(feature = "tracing")]
tracing::debug!(
attribute = %attr.escape_debug(),
"ignoring an unrecognised attribute; the cookie is kept (RFC 6265 §5.2)"
);
report.push(SetCookieIssue::UnknownAttribute { name: attr });
continue;
};
if seen & known.bit() != 0 {
#[cfg(feature = "tracing")]
tracing::debug!(
attribute = known.name(),
"duplicate attribute; the last occurrence that parses wins"
);
report.push(SetCookieIssue::DuplicateAttribute { attribute: known });
}
seen |= known.bit();
let attributes = &mut set_cookie.attributes;
match known {
KnownAttribute::HttpOnly => {
if !val.is_empty() {
report.push(SetCookieIssue::FlagWithValue {
attribute: known,
value: val,
});
}
attributes.http_only = true;
}
KnownAttribute::Secure => {
if !val.is_empty() {
report.push(SetCookieIssue::FlagWithValue {
attribute: known,
value: val,
});
}
attributes.secure = true;
}
KnownAttribute::Partitioned => {
if !val.is_empty() {
report.push(SetCookieIssue::FlagWithValue {
attribute: known,
value: val,
});
}
attributes.partitioned = true;
}
KnownAttribute::SameSite => {
if let Some(v) = noted(val.parse::<SameSite>().ok(), known, val, report) {
attributes.same_site = Some(v);
}
}
KnownAttribute::Path => {
if let Some(v) = noted(Path::new(val).ok(), known, val, report) {
attributes.path = Some(v);
}
}
KnownAttribute::Domain => {
if let Some(v) = noted(Domain::new(val).ok(), known, val, report) {
attributes.domain = Some(v);
}
}
KnownAttribute::MaxAge => {
if let Some(v) = noted(val.parse::<u64>().ok(), known, val, 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, report) {
attributes.expires = Some(v);
}
}
}
}
push_constraint_issues(name, &set_cookie.attributes, report);
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 partitioned(mut self) -> Self {
self.attributes.partitioned = 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: Path<'a>) -> Self {
self.attributes.path = Some(path);
self
}
#[must_use]
pub fn domain(mut self, domain: Domain<'a>) -> Self {
self.attributes.domain = Some(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
}
#[must_use]
pub fn constraint_violations(&self) -> Vec<SetCookieIssue<'static>> {
let mut violations = Vec::new();
push_constraint_issues(self.cookie.name(), &self.attributes, &mut violations);
violations
}
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 {
use std::fmt::Write as _;
let attributes_len: usize = self
.attributes_in_order()
.map(|attribute| 2 + attribute.rendered_len_upper())
.sum();
let mut out = String::with_capacity(
self.cookie.name().len() + 1 + self.cookie.value().len() + attributes_len,
);
self.cookie
.write_pair_into(&mut out, self.cookie.encoding());
for attribute in self.attributes_in_order() {
out.push_str("; ");
write!(out, "{attribute}")
.expect("rendering a Set-Cookie attribute into a String is infallible");
}
out
}
fn attributes_in_order(&self) -> impl Iterator<Item = 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.partitioned.then_some(SetCookieAttribute::Partitioned),
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()
}
#[cfg(test)]
fn set_cookie_attributes(&self) -> Vec<SetCookieAttribute<'a>> {
self.attributes_in_order().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 PARTITIONED: &str = "Partitioned";
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,
Partitioned,
}
impl KnownAttribute {
const ALL: [Self; 8] = [
Self::HttpOnly,
Self::Secure,
Self::SameSite,
Self::Path,
Self::Domain,
Self::MaxAge,
Self::Expires,
Self::Partitioned,
];
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,
Self::Partitioned => attr_name::PARTITIONED,
}
}
fn recognize(attr: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|known| attr.eq_ignore_ascii_case(known.name()))
}
const fn bit(self) -> u16 {
1 << (self as u16)
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SetCookieIssue<'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,
},
#[non_exhaustive]
ConstraintViolation {
constraint: CookieConstraint,
},
}
impl fmt::Display for SetCookieIssue<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
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()
)
}
Self::ConstraintViolation { constraint } => {
write!(f, "{constraint} (cookie kept as written)")
}
}
}
}
impl std::error::Error for SetCookieIssue<'_> {}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CookieConstraint {
NonCanonicalPrefixCase,
SecurePrefixWithoutSecure,
HostPrefixWithoutSecure,
HostPrefixWithDomain,
HostPrefixWithoutRootPath,
PartitionedWithoutSecure,
}
impl fmt::Display for CookieConstraint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::NonCanonicalPrefixCase => {
"a `__Secure-`/`__Host-` prefix spelled in a non-canonical case (the server \
contract is exactly `__Secure-` / `__Host-`)"
}
Self::SecurePrefixWithoutSecure => {
"a `__Secure-`-prefixed cookie requires the `Secure` attribute"
}
Self::HostPrefixWithoutSecure => {
"a `__Host-`-prefixed cookie requires the `Secure` attribute"
}
Self::HostPrefixWithDomain => {
"a `__Host-`-prefixed cookie must not carry a `Domain` attribute"
}
Self::HostPrefixWithoutRootPath => {
"a `__Host-`-prefixed cookie requires `Path=/` exactly"
}
Self::PartitionedWithoutSecure => {
"a `Partitioned` cookie requires the `Secure` attribute (CHIPS)"
}
})
}
}
fn push_constraint_issues<'i>(
name: &str,
attributes: &CookieAttributes<'_>,
report: &mut Vec<SetCookieIssue<'i>>,
) {
let mut note = |constraint: CookieConstraint| {
#[cfg(feature = "tracing")]
tracing::debug!(%constraint, "cross-field constraint violated; the cookie is kept");
report.push(SetCookieIssue::ConstraintViolation { constraint });
};
let secure_prefix = has_secure_prefix(name);
let host_prefix = has_host_prefix(name);
if (secure_prefix && !name.starts_with("__Secure-"))
|| (host_prefix && !name.starts_with("__Host-"))
{
note(CookieConstraint::NonCanonicalPrefixCase);
}
if secure_prefix && !attributes.secure {
note(CookieConstraint::SecurePrefixWithoutSecure);
}
if host_prefix {
if !attributes.secure {
note(CookieConstraint::HostPrefixWithoutSecure);
}
if attributes.domain.is_some() {
note(CookieConstraint::HostPrefixWithDomain);
}
if attributes.path.is_none_or(|p| p.as_str() != "/") {
note(CookieConstraint::HostPrefixWithoutRootPath);
}
}
if attributes.partitioned && !attributes.secure {
note(CookieConstraint::PartitionedWithoutSecure);
}
}
fn noted<'a, T>(
parsed: Option<T>,
attribute: KnownAttribute,
raw_value: &'a str,
report: &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"
);
report.push(SetCookieIssue::InvalidAttributeValue {
attribute,
value: raw_value,
});
}
parsed
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum SetCookieAttribute<'a> {
HttpOnly,
SameSite(SameSite),
Secure,
Partitioned,
Path(&'a str),
Domain(&'a str),
Expires(OffsetDateTime),
MaxAge(u64),
}
impl SetCookieAttribute<'_> {
fn rendered_len_upper(self) -> usize {
match self {
Self::HttpOnly => attr_name::HTTP_ONLY.len(),
Self::SameSite(same_site) => attr_name::SAME_SITE.len() + 1 + same_site.as_str().len(),
Self::Secure => attr_name::SECURE.len(),
Self::Partitioned => attr_name::PARTITIONED.len(),
Self::Path(path) => attr_name::PATH.len() + 1 + path.len(),
Self::Domain(domain) => attr_name::DOMAIN.len() + 1 + domain.len(),
Self::Expires(_) => attr_name::EXPIRES.len() + 1 + 29,
Self::MaxAge(_) => attr_name::MAX_AGE.len() + 1 + 20,
}
}
}
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::Partitioned => f.write_str(attr_name::PARTITIONED),
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, ImfFixdate(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 = 0u16;
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("Priority"), None);
assert_eq!(KnownAttribute::recognize(""), None);
}
#[test]
fn a_duplicate_of_every_attribute_recovers_with_a_witness() {
for (known, dup) in [
(KnownAttribute::HttpOnly, "HttpOnly; HttpOnly"),
(KnownAttribute::Secure, "Secure; Secure"),
(KnownAttribute::Partitioned, "Partitioned; Partitioned"),
(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}");
for (grading, parsed) in [
("strict", SetCookie::parse_strict(&header)),
("lenient", SetCookie::parse(&header)),
] {
let reported = parsed
.unwrap_or_else(|_| panic!("{grading} must keep the cookie for {header:?}"));
assert!(
reported
.issues
.contains(&SetCookieIssue::DuplicateAttribute { attribute: known }),
"{grading} must witness the duplicated {:?} in {header:?}, got {:?}",
known.name(),
reported.issues
);
}
}
assert_eq!(KnownAttribute::ALL.len(), 8);
}
#[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(Domain::new("example.test").unwrap())
.path(Path::new("/app").unwrap())
.partitioned()
.secure()
.same_site(SameSite::None)
.http_only()
.to_set_cookie(),
"n=v; HttpOnly; SameSite=None; Secure; Partitioned; 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(Path::new("/").unwrap())
.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(Path::new("/").unwrap())
.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(Domain::new("example.test").unwrap())
.path(Path::new("/app").unwrap())
.partitioned()
.secure()
.same_site(SameSite::Lax)
.http_only();
assert_eq!(
c.set_cookie_attributes(),
vec![
SetCookieAttribute::HttpOnly,
SetCookieAttribute::SameSite(SameSite::Lax),
SetCookieAttribute::Secure,
SetCookieAttribute::Partitioned,
SetCookieAttribute::Path("/app"),
SetCookieAttribute::Domain("example.test"),
SetCookieAttribute::MaxAge(60),
]
);
assert!(SetCookie::new("n", "v").set_cookie_attributes().is_empty());
}
#[test]
fn to_set_cookie_equals_the_joined_attribute_renderings() {
use time::macros::datetime;
for mask in 0u16..256 {
let mut sc = SetCookie::new("SID", "dead beef");
if mask & 1 != 0 {
sc = sc.http_only();
}
if mask & 2 != 0 {
sc = sc.same_site(SameSite::Lax);
}
if mask & 4 != 0 {
sc = sc.secure();
}
if mask & 8 != 0 {
sc = sc.path(Path::new("/app").unwrap());
}
if mask & 16 != 0 {
sc = sc.domain(Domain::new("example.test").unwrap());
}
if mask & 32 != 0 {
sc = sc.expires(datetime!(2021-06-09 10:18:14 UTC));
}
if mask & 64 != 0 {
sc = sc.max_age(3600);
}
if mask & 128 != 0 {
sc = sc.partitioned();
}
let oracle = std::iter::once(sc.to_request_pair())
.chain(sc.set_cookie_attributes().iter().map(ToString::to_string))
.collect::<Vec<_>>()
.join("; ");
assert_eq!(sc.to_set_cookie(), oracle, "mask {mask:#09b}");
}
}
#[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(Path::new("/x").unwrap())
.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(Path::new("/").unwrap())
.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(Path::new("/").unwrap())
.max_age(3600)
.to_set_cookie();
let parsed = SetCookie::parse(&wire).unwrap().into_value();
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.value(),
"café"
);
assert_eq!(
SetCookie::parse(r#"pref="a b""#).unwrap().value.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()
.into_value();
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 unknown_attribute_is_witnessed_in_both_gradings() {
let strict = SetCookie::parse_strict("SID=x; Priority=High; Max-Age=60").unwrap();
assert!(!strict.is_clean());
assert_eq!(strict.value.attributes().max_age, Some(60));
let p = SetCookie::parse("SID=x; Priority=High; Max-Age=60")
.unwrap()
.into_value();
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()
.into_value();
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()
.into_value();
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.value(), "x");
let p = SetCookie::parse("SID=x; ; Secure").unwrap().into_value();
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()
.into_value();
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()
.value
.attributes()
.max_age,
Some(u64::MAX)
);
assert_eq!(
SetCookie::parse("n=v; Max-Age=-1")
.unwrap()
.value
.attributes()
.max_age,
None
);
}
#[test]
fn parse_rejects_no_equals_and_bad_name() {
assert!(SetCookie::parse("HttpOnly").is_err()); assert!(SetCookie::parse("na me=v; Secure").is_err()); assert!(SetCookie::parse("").is_err());
assert!(SetCookie::parse("=v").is_err()); }
#[test]
fn parse_splits_first_semicolon_then_first_equals() {
let p = SetCookie::parse("a=b=c; Path=/x").unwrap().into_value();
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 mistyped_or_fused_attribute_is_reported_not_silent() {
let reported = SetCookie::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::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"
}]
);
let strict = SetCookie::parse_strict("SID=x; HttpOnlyy").unwrap();
assert!(!strict.is_clean());
assert_eq!(
strict.issues,
vec![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::parse(header).unwrap();
assert_eq!(lenient.issues, expected, "lenient {header:?}");
let strict = SetCookie::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::parse(rfc850).unwrap().is_clean());
let strict = SetCookie::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::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);
let strict = SetCookie::parse_strict("n=v; Path=/a; Path=/b").unwrap();
assert_eq!(
strict.issues,
vec![SetCookieIssue::DuplicateAttribute {
attribute: KnownAttribute::Path
}]
);
}
#[test]
fn fatal_pair_issues_carry_the_pair_defect() {
assert_eq!(
SetCookie::parse("HttpOnly"),
Err(PairIssue::MissingEquals {
segment: b"HttpOnly"
})
);
assert_eq!(
SetCookie::parse("na me=v; Secure"),
Err(PairIssue::InvalidName { name: b"na me" })
);
assert_eq!(
SetCookie::parse("n=a\u{1}b; Secure"),
Err(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()
.into_value();
assert_eq!(p.attributes().max_age, Some(60));
let p = SetCookie::parse("n=v; Domain=valid.example.com; Domain=café")
.unwrap()
.into_value();
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()
.into_value();
assert_eq!(p.attributes().max_age, Some(2));
let p = SetCookie::parse("n=v; Max-Age=banana")
.unwrap()
.into_value();
assert_eq!(p.attributes().max_age, None);
let reported = SetCookie::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::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",
},
SetCookieIssue::ConstraintViolation {
constraint: CookieConstraint::HostPrefixWithoutRootPath,
},
];
for issue in issues {
let rendered = issue.to_string();
for byte in *b"\r\n\0" {
assert!(
!rendered.bytes().any(|b| b == byte),
"{rendered:?} echoes {byte:#04x}"
);
}
}
}
#[test]
fn empty_attribute_name_with_a_value_is_witnessed() {
for wire in ["SID=x; =evil", "SID=x; \t= y"] {
for (grading, parsed) in [
("lenient", SetCookie::parse(wire)),
("strict", SetCookie::parse_strict(wire)),
] {
let reported = parsed.unwrap_or_else(|_| panic!("{wire:?} must salvage"));
assert!(
matches!(
reported.issues[..],
[SetCookieIssue::UnknownAttribute { name: "" }]
),
"{grading} must witness the empty-name attribute in {wire:?}, got {:?}",
reported.issues
);
}
}
assert!(SetCookie::parse("SID=x; ; \t;").unwrap().is_clean());
assert!(SetCookie::parse_strict("SID=x; = ;").unwrap().is_clean());
}
fn constraints_of(issues: &[SetCookieIssue<'_>]) -> Vec<CookieConstraint> {
issues
.iter()
.filter_map(|issue| match issue {
SetCookieIssue::ConstraintViolation { constraint } => Some(*constraint),
_ => None,
})
.collect()
}
#[test]
fn every_constraint_is_witnessed_and_nothing_is_dropped() {
use CookieConstraint::*;
for (wire, expected) in [
("__Secure-a=b", &[SecurePrefixWithoutSecure][..]),
("__secure-a=b; Secure", &[NonCanonicalPrefixCase]),
("__Secure-a=b; Secure", &[]),
("__Host-a=b; Secure; Path=/", &[]),
("__Host-a=b; Path=/", &[HostPrefixWithoutSecure]),
(
"__Host-a=b; Secure; Path=/; Domain=example.test",
&[HostPrefixWithDomain],
),
(
"__Host-a=b; Secure; Path=/app",
&[HostPrefixWithoutRootPath],
),
("__Host-a=b; Secure", &[HostPrefixWithoutRootPath]),
(
"__Host-a=b",
&[HostPrefixWithoutSecure, HostPrefixWithoutRootPath],
),
(
"__hOsT-a=b",
&[
NonCanonicalPrefixCase,
HostPrefixWithoutSecure,
HostPrefixWithoutRootPath,
],
),
("a=b; Partitioned", &[PartitionedWithoutSecure]),
("a=b; Partitioned; Secure", &[]),
("a=b; Secure", &[]),
] {
for (grading, parsed) in [
("lenient", SetCookie::parse(wire)),
("strict", SetCookie::parse_strict(wire)),
] {
let reported = parsed.unwrap_or_else(|_| panic!("{wire:?} must salvage"));
assert_eq!(
constraints_of(&reported.issues),
expected,
"{grading} constraint issues for {wire:?}"
);
assert!(
reported.issues.len() == expected.len(),
"{grading} must witness only constraints for {wire:?}: {:?}",
reported.issues
);
}
}
let kept = SetCookie::parse("__Host-a=b; Secure; Path=/app; Domain=example.test")
.unwrap()
.value;
assert_eq!(kept.attributes().path.map(|p| p.as_str()), Some("/app"));
assert_eq!(
kept.attributes().domain.map(|d| d.as_str()),
Some("example.test")
);
let kept = SetCookie::parse("a=b; Partitioned").unwrap().value;
assert!(kept.attributes().partitioned);
}
#[test]
fn prefix_constraints_match_case_insensitively() {
for wire in ["__SECURE-a=b", "__secure-a=b", "__SeCuRe-a=b"] {
assert_eq!(
constraints_of(&SetCookie::parse(wire).unwrap().issues),
[
CookieConstraint::NonCanonicalPrefixCase,
CookieConstraint::SecurePrefixWithoutSecure,
],
"{wire:?}"
);
}
assert_eq!(
constraints_of(&SetCookie::parse("__host-a=b; Secure").unwrap().issues),
[
CookieConstraint::NonCanonicalPrefixCase,
CookieConstraint::HostPrefixWithoutRootPath,
],
"a case-variant `__host-` still triggers the prefix rules"
);
assert_eq!(
constraints_of(
&SetCookie::parse("__host-a=b; Secure; Path=/")
.unwrap()
.issues
),
[CookieConstraint::NonCanonicalPrefixCase],
);
assert!(
SetCookie::parse("__Secure-a=b; Secure").unwrap().is_clean()
&& SetCookie::parse("__Host-a=b; Secure; Path=/")
.unwrap()
.is_clean()
);
}
#[test]
fn constraint_issues_follow_the_wire_order_attribute_issues() {
let parsed = SetCookie::parse("__Secure-a=b; Priority=x; Max-Age=banana").unwrap();
assert!(matches!(
parsed.issues[..],
[
SetCookieIssue::UnknownAttribute {
name: "Priority",
..
},
SetCookieIssue::InvalidAttributeValue {
attribute: KnownAttribute::MaxAge,
..
},
SetCookieIssue::ConstraintViolation {
constraint: CookieConstraint::SecurePrefixWithoutSecure,
..
},
]
));
}
#[test]
fn constraint_violations_agree_with_the_parse() {
let built = SetCookie::new("__Host-SID", "x").domain(Domain::new("example.test").unwrap());
let violations = built.constraint_violations();
assert_eq!(
constraints_of(&violations),
[
CookieConstraint::HostPrefixWithoutSecure,
CookieConstraint::HostPrefixWithDomain,
CookieConstraint::HostPrefixWithoutRootPath,
]
);
let rendered = built.to_set_cookie();
let reparsed = SetCookie::parse(&rendered).unwrap();
assert_eq!(reparsed.issues, violations, "for {rendered:?}");
let ok = SetCookie::new("__Host-SID", "x")
.secure()
.path(Path::new("/").unwrap());
assert!(ok.constraint_violations().is_empty());
assert!(
SetCookie::parse_strict(&ok.to_set_cookie())
.unwrap()
.is_clean()
);
}
}