use std::fmt;
use rfc_6265::OffsetDateTime;
use rfc_6265::grammar::is_av_octet;
use crate::same_site::SameSite;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Path<'a>(&'a str);
impl<'a> Path<'a> {
pub fn new(value: &'a str) -> Result<Self, InvalidPath<'a>> {
if value.bytes().all(is_av_octet) {
Ok(Self(value))
} else {
Err(InvalidPath { value })
}
}
pub const fn as_str(&self) -> &'a str {
self.0
}
}
impl AsRef<str> for Path<'_> {
fn as_ref(&self) -> &str {
self.0
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct InvalidPath<'a> {
pub value: &'a str,
}
impl fmt::Display for InvalidPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"cookie Path `{}` carries a byte outside the av-octet set",
self.value.escape_debug()
)
}
}
impl std::error::Error for InvalidPath<'_> {}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Domain<'a>(&'a str);
impl<'a> Domain<'a> {
pub fn new(value: &'a str) -> Result<Self, InvalidDomain<'a>> {
if !value.bytes().all(is_av_octet) {
return Err(InvalidDomain::NotAvOctets { value });
}
#[cfg(any(feature = "psl", feature = "idna"))]
if !rfc_6265::domain::is_host_name(value.strip_prefix('.').unwrap_or(value)) {
return Err(InvalidDomain::NotAHostName { value });
}
#[cfg(feature = "psl")]
if rfc_6265::domain::is_public_suffix(value) {
return Err(InvalidDomain::PublicSuffix { value });
}
#[cfg(feature = "idna")]
if !rfc_6265::domain::is_valid_domain(value) {
return Err(InvalidDomain::MalformedIdn { value });
}
Ok(Self(value))
}
pub const fn as_str(&self) -> &'a str {
self.0
}
}
impl AsRef<str> for Domain<'_> {
fn as_ref(&self) -> &str {
self.0
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InvalidDomain<'a> {
#[non_exhaustive]
NotAvOctets {
value: &'a str,
},
#[non_exhaustive]
NotAHostName {
value: &'a str,
},
#[non_exhaustive]
PublicSuffix {
value: &'a str,
},
#[non_exhaustive]
MalformedIdn {
value: &'a str,
},
}
impl fmt::Display for InvalidDomain<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotAvOctets { value } => write!(
f,
"cookie Domain `{}` carries a byte outside the av-octet set",
value.escape_debug()
),
Self::NotAHostName { value } => write!(
f,
"cookie Domain `{}` is not an LDH host name",
value.escape_debug()
),
Self::PublicSuffix { value } => write!(
f,
"cookie Domain `{}` is a public suffix",
value.escape_debug()
),
Self::MalformedIdn { value } => write!(
f,
"cookie Domain `{}` has no canonical ASCII form",
value.escape_debug()
),
}
}
}
impl std::error::Error for InvalidDomain<'_> {}
#[derive(Default, Clone, Debug, PartialEq, Eq, Hash)]
pub struct CookieAttributes<'a> {
pub http_only: bool,
pub secure: bool,
pub partitioned: bool,
pub same_site: Option<SameSite>,
pub path: Option<Path<'a>>,
pub domain: Option<Domain<'a>>,
pub max_age: Option<u64>,
pub expires: Option<OffsetDateTime>,
}
impl<'a> CookieAttributes<'a> {
#[must_use]
pub fn http_only(mut self) -> Self {
self.http_only = true;
self
}
#[must_use]
pub fn secure(mut self) -> Self {
self.secure = true;
self
}
#[must_use]
pub fn partitioned(mut self) -> Self {
self.partitioned = true;
self
}
#[must_use]
pub fn same_site(mut self, same_site: SameSite) -> Self {
self.same_site = Some(same_site);
self
}
#[must_use]
pub fn path(mut self, path: Path<'a>) -> Self {
self.path = Some(path);
self
}
#[must_use]
pub fn domain(mut self, domain: Domain<'a>) -> Self {
self.domain = Some(domain);
self
}
#[must_use]
pub fn max_age(mut self, seconds: u64) -> Self {
self.max_age = Some(seconds);
self
}
#[must_use]
pub fn expires(mut self, when: OffsetDateTime) -> Self {
self.expires = Some(when);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_unset() {
let a = CookieAttributes::default();
assert!(!a.http_only);
assert!(!a.secure);
assert!(!a.partitioned);
assert_eq!(a.same_site, None);
assert_eq!(a.path, None);
assert_eq!(a.domain, None);
assert_eq!(a.max_age, None);
}
#[test]
fn field_read_and_same_named_setter_coexist() {
let a = CookieAttributes::default();
let off: bool = a.secure;
assert!(!off);
let a = a.secure();
assert!(a.secure);
}
#[test]
fn builders_set_each_attribute() {
let a = CookieAttributes::default()
.http_only()
.secure()
.partitioned()
.same_site(SameSite::Lax)
.path(Path::new("/app").unwrap())
.domain(Domain::new("example.test").unwrap())
.max_age(60);
assert!(a.http_only && a.secure && a.partitioned);
assert_eq!(a.same_site, Some(SameSite::Lax));
assert_eq!(a.path, Path::new("/app").ok());
assert_eq!(a.domain, Domain::new("example.test").ok());
assert_eq!(a.max_age, Some(60));
}
#[test]
fn path_and_domain_reject_injection() {
assert_eq!(Path::new("/a;b"), Err(InvalidPath { value: "/a;b" }));
assert_eq!(Path::new("/a\r\nb"), Err(InvalidPath { value: "/a\r\nb" }));
assert_eq!(
Domain::new("ex\0ample"),
Err(InvalidDomain::NotAvOctets { value: "ex\0ample" })
);
assert_eq!(Path::new("/ok").map(|p| p.as_str()), Ok("/ok"));
}
#[test]
fn refusals_render_without_control_bytes() {
let rendered = [
Path::new("/a\r\n\0b").unwrap_err().to_string(),
Domain::new("ex\0am\rple").unwrap_err().to_string(),
];
for line in rendered {
assert!(
!line.bytes().any(|b| b.is_ascii_control()),
"{line:?} carries a raw control byte"
);
}
}
#[test]
fn as_ref_matches_as_str() {
fn borrow(s: impl AsRef<str>) -> String {
s.as_ref().to_owned()
}
let p = Path::new("/app").unwrap();
assert_eq!(p.as_ref(), p.as_str());
assert_eq!(borrow(p), "/app");
let d = Domain::new("example.test").unwrap();
assert_eq!(d.as_ref(), d.as_str());
assert_eq!(borrow(d), "example.test");
}
#[test]
fn path_domain_av_octet_edge_cases() {
assert_eq!(Path::new("").map(|p| p.as_str()), Ok(""));
#[cfg(not(any(feature = "psl", feature = "idna")))]
assert_eq!(Domain::new("").map(|d| d.as_str()), Ok(""));
#[cfg(any(feature = "psl", feature = "idna"))]
assert_eq!(
Domain::new(""),
Err(InvalidDomain::NotAHostName { value: "" })
);
assert!(Path::new(" ").is_ok());
assert!(Path::new(" ").is_ok());
assert!(Path::new("\t").is_err());
assert!(Path::new("a\tb").is_err());
assert!(Path::new("12345").is_ok());
#[cfg(not(feature = "psl"))]
assert!(Domain::new("123").is_ok());
}
#[cfg(any(feature = "psl", feature = "idna"))]
#[test]
fn hardening_features_require_host_name_syntax() {
for refused in [
"ex_ample.com", "a..b", "example.com.", "exa mple.com", "example.com:8080", ] {
assert_eq!(
Domain::new(refused),
Err(InvalidDomain::NotAHostName { value: refused })
);
}
assert!(Domain::new(".example.com").is_ok());
assert!(Domain::new("192.168.0.1").is_ok());
assert!(Domain::new("my-host.example.com").is_ok());
}
#[cfg(feature = "psl")]
#[test]
fn psl_feature_rejects_public_suffix_domains() {
for refused in ["com", "co.uk", ".com"] {
assert_eq!(
Domain::new(refused),
Err(InvalidDomain::PublicSuffix { value: refused })
);
}
assert!(Domain::new("example.com").is_ok());
assert!(Domain::new("example.co.uk").is_ok());
}
#[cfg(feature = "idna")]
#[test]
fn idna_feature_rejects_malformed_punycode() {
assert_eq!(
Domain::new("xn--.de"),
Err(InvalidDomain::MalformedIdn { value: "xn--.de" })
);
assert!(Domain::new("xn--").is_err());
assert!(Domain::new("xn--mnchen-3ya.de").is_ok()); assert!(Domain::new("example.com").is_ok());
}
}