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) -> Option<Self> {
value.bytes().all(is_av_octet).then_some(Self(value))
}
pub const fn as_str(&self) -> &'a str {
self.0
}
}
impl AsRef<str> for Path<'_> {
fn as_ref(&self) -> &str {
self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Domain<'a>(&'a str);
impl<'a> Domain<'a> {
pub fn new(value: &'a str) -> Option<Self> {
if !value.bytes().all(is_av_octet) {
return None;
}
#[cfg(any(feature = "psl", feature = "idna"))]
if !rfc_6265::domain::is_host_name(value.strip_prefix('.').unwrap_or(value)) {
return None;
}
#[cfg(feature = "psl")]
if rfc_6265::domain::is_public_suffix(value) {
return None;
}
#[cfg(feature = "idna")]
if !rfc_6265::domain::is_valid_domain(value) {
return None;
}
Some(Self(value))
}
pub const fn as_str(&self) -> &'a str {
self.0
}
}
impl AsRef<str> for Domain<'_> {
fn as_ref(&self) -> &str {
self.0
}
}
#[derive(Default, Clone, Debug, PartialEq, Eq, Hash)]
pub struct CookieAttributes<'a> {
pub http_only: bool,
pub secure: 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 same_site(mut self, same_site: SameSite) -> Self {
self.same_site = Some(same_site);
self
}
#[must_use]
pub fn path(mut self, path: &'a str) -> Self {
self.path = Path::new(path);
self
}
#[must_use]
pub fn domain(mut self, domain: &'a str) -> Self {
self.domain = Domain::new(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_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()
.same_site(SameSite::Lax)
.path("/app")
.domain("example.test")
.max_age(60);
assert!(a.http_only && a.secure);
assert_eq!(a.same_site, Some(SameSite::Lax));
assert_eq!(a.path, Path::new("/app"));
assert_eq!(a.domain, Domain::new("example.test"));
assert_eq!(a.max_age, Some(60));
}
#[test]
fn path_and_domain_reject_injection() {
assert_eq!(Path::new("/a;b"), None);
assert_eq!(Path::new("/a\r\nb"), None);
assert_eq!(Domain::new("ex\0ample"), None);
assert_eq!(CookieAttributes::default().path("/a\0b").path, None);
assert_eq!(Path::new("/ok").map(|p| p.as_str()), Some("/ok"));
}
#[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()), Some(""));
#[cfg(not(any(feature = "psl", feature = "idna")))]
assert_eq!(Domain::new("").map(|d| d.as_str()), Some(""));
#[cfg(any(feature = "psl", feature = "idna"))]
assert!(Domain::new("").is_none());
assert!(Path::new(" ").is_some());
assert!(Path::new(" ").is_some());
assert!(Path::new("\t").is_none());
assert!(Path::new("a\tb").is_none());
assert!(Path::new("12345").is_some());
#[cfg(not(feature = "psl"))]
assert!(Domain::new("123").is_some());
}
#[cfg(any(feature = "psl", feature = "idna"))]
#[test]
fn hardening_features_require_host_name_syntax() {
assert!(Domain::new("ex_ample.com").is_none()); assert!(Domain::new("a..b").is_none()); assert!(Domain::new("example.com.").is_none()); assert!(Domain::new("exa mple.com").is_none()); assert!(Domain::new("example.com:8080").is_none()); assert!(Domain::new(".example.com").is_some());
assert!(Domain::new("192.168.0.1").is_some());
assert!(Domain::new("my-host.example.com").is_some());
}
#[cfg(feature = "psl")]
#[test]
fn psl_feature_rejects_public_suffix_domains() {
assert!(Domain::new("com").is_none());
assert!(Domain::new("co.uk").is_none());
assert!(Domain::new(".com").is_none());
assert!(Domain::new("example.com").is_some());
assert!(Domain::new("example.co.uk").is_some());
}
#[cfg(feature = "idna")]
#[test]
fn idna_feature_rejects_malformed_punycode() {
assert!(Domain::new("xn--").is_none()); assert!(Domain::new("xn--mnchen-3ya.de").is_some()); assert!(Domain::new("example.com").is_some());
}
}