use std::borrow::Cow;
use std::io::{Read, Write as _};
use std::path::Path;
use url::{Host, Url};
use crate::error::Error;
const MAX_COOKIES: usize = 3000;
const MAX_COOKIE_NAME_VALUE_BYTES: usize = 4096;
const MAX_COOKIE_DOMAIN_BYTES: usize = 253;
const MAX_COOKIE_PATH_BYTES: usize = 1024;
const MAX_COOKIE_HEADER_BYTES: usize = 8190;
const MAX_LINE_BYTES: usize = MAX_COOKIE_NAME_VALUE_BYTES + MAX_COOKIE_DOMAIN_BYTES + MAX_COOKIE_PATH_BYTES + 64;
const MAX_FILE_BYTES: u64 = (MAX_COOKIES * MAX_LINE_BYTES) as u64;
const NETSCAPE_HEADER: &str = concat!(
"# Netscape HTTP Cookie File\n",
"# https://curl.se/docs/http-cookies.html\n",
"# This file was generated by servo-fetch! Edit at your own risk.\n\n",
);
#[derive(Debug, thiserror::Error)]
#[error("too many cookies (max {MAX_COOKIES})")]
pub(crate) struct TooManyCookies;
#[derive(Clone, PartialEq, Eq)]
pub struct CookieSpec {
name: String,
value: String,
host: Host<String>,
path: String,
expires: Option<i64>,
secure: bool,
http_only: bool,
host_only: bool,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidCookie {
#[error("cookie name is empty")]
EmptyName,
#[error("cookie name or value contains an illegal character")]
IllegalChar,
#[error("cookie name and value exceed {MAX_COOKIE_NAME_VALUE_BYTES} bytes")]
TooLarge,
#[error("cookie host is invalid")]
InvalidHost,
#[error("cookie path is invalid")]
InvalidPath,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CookieWire {
name: String,
value: String,
domain: String,
path: String,
expires: Option<i64>,
secure: bool,
http_only: bool,
include_subdomains: bool,
}
impl From<CookieSpec> for CookieWire {
fn from(spec: CookieSpec) -> Self {
Self {
name: spec.name,
value: spec.value,
domain: spec.host.to_string(),
path: spec.path,
expires: spec.expires,
secure: spec.secure,
http_only: spec.http_only,
include_subdomains: !spec.host_only,
}
}
}
impl TryFrom<CookieWire> for CookieSpec {
type Error = InvalidCookie;
fn try_from(wire: CookieWire) -> Result<Self, Self::Error> {
Ok(Self::new(wire.name, wire.value, &wire.domain)?
.path(wire.path)?
.secure(wire.secure)
.http_only(wire.http_only)
.include_subdomains(wire.include_subdomains)
.expires(wire.expires))
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum WireError {
#[error(transparent)]
TooMany(#[from] TooManyCookies),
#[error(transparent)]
Invalid(#[from] InvalidCookie),
}
pub(crate) fn from_wire(wires: Vec<CookieWire>) -> Result<Vec<CookieSpec>, WireError> {
if wires.len() > MAX_COOKIES {
return Err(TooManyCookies.into());
}
Ok(wires
.into_iter()
.map(CookieSpec::try_from)
.collect::<Result<_, InvalidCookie>>()?)
}
impl std::fmt::Debug for CookieSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CookieSpec")
.field("name", &self.name)
.field("value", &"<redacted>")
.field("host", &self.host)
.field("path", &self.path)
.field("expires", &self.expires)
.field("secure", &self.secure)
.field("http_only", &self.http_only)
.field("host_only", &self.host_only)
.finish()
}
}
impl CookieSpec {
pub fn new(name: impl Into<String>, value: impl Into<String>, domain: &str) -> Result<Self, InvalidCookie> {
let name = name.into();
let value = value.into();
if name.is_empty() {
return Err(InvalidCookie::EmptyName);
}
if has_control(&name) || name.contains([';', '=']) || has_control(&value) || value.contains(';') {
return Err(InvalidCookie::IllegalChar);
}
if name.len() + value.len() > MAX_COOKIE_NAME_VALUE_BYTES {
return Err(InvalidCookie::TooLarge);
}
Ok(Self {
name,
value,
host: parse_cookie_host(domain)?,
path: String::from("/"),
expires: None,
secure: false,
http_only: false,
host_only: true,
})
}
pub fn path(mut self, path: impl Into<String>) -> Result<Self, InvalidCookie> {
let path = path.into();
if !path.starts_with('/') {
self.path = String::from("/");
return Ok(self);
}
if path.len() > MAX_COOKIE_PATH_BYTES || has_control(&path) {
return Err(InvalidCookie::InvalidPath);
}
self.path = path;
Ok(self)
}
#[must_use]
pub fn expires_at(mut self, unix_seconds: i64) -> Self {
self.expires = Some(unix_seconds);
self
}
fn expires(mut self, expires: Option<i64>) -> Self {
self.expires = expires;
self
}
#[must_use]
pub fn secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
#[must_use]
pub fn http_only(mut self, http_only: bool) -> Self {
self.http_only = http_only;
self
}
#[must_use]
pub fn include_subdomains(mut self, include_subdomains: bool) -> Self {
self.host_only = !include_subdomains;
self
}
fn key(&self) -> (&str, &Host<String>, &str) {
(&self.name, &self.host, &self.path)
}
fn is_expired(&self, now: i64) -> bool {
self.expires.is_some_and(|expiry| expiry <= now)
}
fn domain_matches(&self, target: &Url) -> bool {
match (target.host(), &self.host) {
(Some(Host::Domain(request)), Host::Domain(cookie)) => {
request == cookie
|| (!self.host_only
&& request
.strip_suffix(cookie.as_str())
.is_some_and(|prefix| prefix.ends_with('.')))
}
(Some(Host::Ipv4(request)), Host::Ipv4(cookie)) => request == *cookie,
(Some(Host::Ipv6(request)), Host::Ipv6(cookie)) => request == *cookie,
_ => false,
}
}
fn jar_entry(&self, target: &Url, policy: crate::net::NetworkPolicy) -> Option<(Url, cookie::Cookie<'static>)> {
if self.is_expired(now_unix()) {
return None;
}
let host = self.host.to_string();
let scheme = if self.secure { "https" } else { "http" };
let url = Url::parse(&format!("{scheme}://{host}{}", self.path)).ok()?;
if crate::net::validate_url_with_policy(url.as_str(), policy).is_err()
|| !crate::scope::is_same_site(target, &url)
{
tracing::warn!(domain = %host, "skipped out-of-scope or disallowed cookie");
return None;
}
let mut builder = cookie::Cookie::build((self.name.clone(), self.value.clone()))
.path(self.path.clone())
.secure(self.secure)
.http_only(self.http_only);
if let Some(expires) = self.expires
&& let Ok(expires) = cookie::time::OffsetDateTime::from_unix_timestamp(expires)
{
builder = builder.expires(expires);
}
if !self.host_only {
builder = builder.domain(host);
}
Some((url, builder.build()))
}
fn write_netscape(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
let http_only = if self.http_only { "#HttpOnly_" } else { "" };
let leading_dot = if self.host_only { "" } else { "." };
let include_subdomains = if self.host_only { "FALSE" } else { "TRUE" };
let secure = if self.secure { "TRUE" } else { "FALSE" };
let expires = self.expires.unwrap_or(0);
let host = match &self.host {
Host::Ipv6(address) => address.to_string(),
host => host.to_string(),
};
let Self { path, name, value, .. } = self;
writeln!(
writer,
"{http_only}{leading_dot}{host}\t{include_subdomains}\t{path}\t{secure}\t{expires}\t{name}\t{value}"
)
}
fn parse_netscape(line: &str) -> Result<Option<Self>, NetscapeLineError> {
let (http_only, rest) = match line.strip_prefix("#HttpOnly_") {
Some(rest) => (true, rest),
None if line.trim().is_empty() || line.starts_with('#') => return Ok(None),
None => (false, line),
};
let fields = rest.split('\t').collect::<Vec<_>>();
let found = fields.len();
let [domain, include_subdomains, path, secure, expires, name, ref value @ ..] = fields[..] else {
return Err(NetscapeLineError::FieldCount { found });
};
let value = match value {
[] => "",
[value] => value,
_ => return Err(NetscapeLineError::FieldCount { found }),
};
let expires = match expires
.split('.')
.next()
.and_then(|value| value.trim().parse::<i64>().ok())
{
Some(0) => None,
Some(expiry) if expiry > 0 => Some(expiry),
_ => return Ok(None),
};
Ok(Some(
Self::new(name, value, domain)?
.path(path)?
.secure(secure.eq_ignore_ascii_case("TRUE"))
.http_only(http_only)
.include_subdomains(include_subdomains.eq_ignore_ascii_case("TRUE"))
.expires(expires),
))
}
pub(crate) fn from_cookie(
cookie: &cookie::Cookie<'_>,
queried_url: &Url,
host_only: bool,
) -> Result<Self, InvalidCookie> {
let domain = cookie
.domain()
.unwrap_or_else(|| queried_url.host_str().unwrap_or_default());
Ok(Self::new(cookie.name(), cookie.value(), domain)?
.path(cookie.path().unwrap_or("/"))?
.secure(cookie.secure().unwrap_or(false))
.http_only(cookie.http_only().unwrap_or(false))
.include_subdomains(!host_only)
.expires(
cookie
.expires_datetime()
.map(cookie::time::OffsetDateTime::unix_timestamp),
))
}
}
#[derive(Debug, thiserror::Error)]
enum NetscapeLineError {
#[error("expected 7 tab-separated fields, found {found}")]
FieldCount { found: usize },
#[error(transparent)]
InvalidCookie(#[from] InvalidCookie),
}
#[derive(Debug, thiserror::Error)]
#[error("failed to save cookies to {}", path.display())]
struct SaveError {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
}
#[derive(Debug, thiserror::Error)]
enum LoadError {
#[error("file exceeds {MAX_FILE_BYTES} bytes")]
TooLarge,
#[error("line {line}")]
Line {
line: usize,
#[source]
source: NetscapeLineError,
},
#[error(transparent)]
TooMany(#[from] TooManyCookies),
}
pub fn save_cookies(path: impl AsRef<Path>, cookies: &[CookieSpec]) -> crate::error::Result<()> {
let path = path.as_ref();
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let fail = |source: std::io::Error| {
Error::Io(std::io::Error::new(
source.kind(),
SaveError {
path: path.to_path_buf(),
source,
},
))
};
let mut temp = tempfile::Builder::new()
.prefix(path.file_name().unwrap_or_default())
.suffix(".tmp")
.tempfile_in(parent)
.map_err(fail)?;
temp.write_all(NETSCAPE_HEADER.as_bytes()).map_err(fail)?;
for cookie in cookies {
cookie.write_netscape(&mut temp).map_err(fail)?;
}
temp.as_file_mut().sync_all().map_err(fail)?;
temp.persist(path).map_err(|error| fail(error.error))?;
Ok(())
}
pub fn load_cookies(path: impl AsRef<Path>) -> crate::error::Result<Vec<CookieSpec>> {
let path = path.as_ref();
let fail = |source: crate::error::BoxError| Error::Cookies {
path: path.to_path_buf(),
source,
};
let mut text = String::new();
std::fs::File::open(path)
.and_then(|file| file.take(MAX_FILE_BYTES + 1).read_to_string(&mut text))
.map_err(|error| fail(error.into()))?;
if text.len() as u64 > MAX_FILE_BYTES {
return Err(fail(LoadError::TooLarge.into()));
}
fold_netscape(text.strip_prefix('\u{FEFF}').unwrap_or(&text)).map_err(|error| fail(error.into()))
}
fn fold_netscape(text: &str) -> Result<Vec<CookieSpec>, LoadError> {
let now = now_unix();
let mut cookies = Vec::new();
for (line_index, line) in text.lines().enumerate() {
let Some(cookie) = CookieSpec::parse_netscape(line).map_err(|source| LoadError::Line {
line: line_index + 1,
source,
})?
else {
continue;
};
let existing = cookies
.iter()
.position(|current: &CookieSpec| current.key() == cookie.key());
match (existing, cookie.is_expired(now)) {
(Some(index), true) => {
cookies.remove(index);
}
(None, true) => {}
(Some(index), false) => cookies[index] = cookie,
(None, false) if cookies.len() >= MAX_COOKIES => return Err(TooManyCookies.into()),
(None, false) => cookies.push(cookie),
}
}
Ok(cookies)
}
fn parse_cookie_host(domain: &str) -> Result<Host<String>, InvalidCookie> {
let domain = domain.strip_prefix('.').unwrap_or(domain);
let input = if domain.contains(':') && !domain.starts_with('[') {
Cow::Owned(format!("[{domain}]"))
} else {
Cow::Borrowed(domain)
};
let host = Host::parse(&input).map_err(|_| InvalidCookie::InvalidHost)?;
if matches!(&host, Host::Domain(domain) if domain.len() > MAX_COOKIE_DOMAIN_BYTES) {
return Err(InvalidCookie::InvalidHost);
}
Ok(host)
}
fn is_secure_context(target: &Url) -> bool {
target.scheme() == "https"
|| match target.host() {
Some(Host::Domain(domain)) => domain == "localhost" || domain.ends_with(".localhost"),
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
None => false,
}
}
pub(crate) fn seed(servo: &servo::Servo, target: &Url, specs: &[CookieSpec]) {
let policy = crate::bridge::engine_policy();
let manager = servo.site_data_manager();
for spec in specs {
if let Some((url, cookie)) = spec.jar_entry(target, policy) {
manager.set_cookie_for_url(url, cookie, None);
}
}
}
fn probe_host_only(manager: &servo::SiteDataManager, cookie: &cookie::Cookie<'_>, queried_url: &Url) -> Option<bool> {
let domain = cookie.domain()?;
let cookie_host = parse_cookie_host(domain).ok()?;
let queried_host = queried_url.host()?;
if cookie_host != queried_host {
return Some(false);
}
let Host::Domain(domain) = cookie_host else {
return Some(true);
};
let mut probe = queried_url.clone();
if cookie.secure().unwrap_or(false) {
probe.set_scheme("https").ok()?;
}
probe
.set_host(Some(&format!("servo-fetch-cookie-scope.{domain}")))
.ok()?;
probe.set_path(cookie.path().unwrap_or("/"));
Some(
!manager
.cookies_for_url(probe, servo::CookieSource::HTTP)
.iter()
.any(|candidate| {
candidate.name() == cookie.name()
&& candidate.domain() == cookie.domain()
&& candidate.path() == cookie.path()
}),
)
}
pub(crate) fn capture(servo: &servo::Servo, urls: &[Url]) -> Vec<CookieSpec> {
let manager = servo.site_data_manager();
let mut captured: Vec<CookieSpec> = Vec::new();
for url in urls {
for cookie in manager.cookies_for_url(url.clone(), servo::CookieSource::HTTP) {
let host_only = probe_host_only(manager, &cookie, url).unwrap_or(true);
let Ok(spec) = CookieSpec::from_cookie(&cookie, url, host_only)
.inspect_err(|error| tracing::warn!(%error, "dropped a captured cookie"))
else {
continue;
};
if !captured.iter().any(|existing| existing.key() == spec.key()) {
captured.push(spec);
}
}
}
if captured.len() > MAX_COOKIES {
tracing::warn!(
dropped = captured.len() - MAX_COOKIES,
max = MAX_COOKIES,
"dropped captured cookies after reaching limit"
);
captured.truncate(MAX_COOKIES);
}
captured
}
pub(crate) fn seedable(target: &Url, specs: &[CookieSpec]) -> Vec<CookieSpec> {
let policy = crate::bridge::engine_policy();
specs
.iter()
.filter(|spec| spec.jar_entry(target, policy).is_some())
.cloned()
.collect()
}
pub(crate) fn request_header(target: &Url, specs: &[CookieSpec]) -> Option<http::HeaderValue> {
let request_path = target.path();
let secure = is_secure_context(target);
let now = now_unix();
let mut matches = specs
.iter()
.filter(|spec| {
!spec.is_expired(now)
&& spec.domain_matches(target)
&& (!spec.secure || secure)
&& path_matches(request_path, &spec.path)
})
.collect::<Vec<_>>();
matches.sort_by_key(|spec| std::cmp::Reverse(spec.path.len()));
let mut value = String::new();
for spec in matches {
let separator = if value.is_empty() { "" } else { "; " };
let projected = "Cookie: ".len() + value.len() + separator.len() + spec.name.len() + 1 + spec.value.len();
if projected >= MAX_COOKIE_HEADER_BYTES {
tracing::warn!(name = %spec.name, "omitted cookies after reaching outgoing header limit");
break;
}
value.push_str(separator);
value.push_str(&spec.name);
value.push('=');
value.push_str(&spec.value);
}
if value.is_empty() {
None
} else {
http::HeaderValue::from_str(&value).ok()
}
}
fn path_matches(request_path: &str, cookie_path: &str) -> bool {
request_path == cookie_path
|| request_path
.strip_prefix(cookie_path)
.is_some_and(|suffix| cookie_path.ends_with('/') || suffix.starts_with('/'))
}
fn has_control(value: &str) -> bool {
value.bytes().any(|byte| byte.is_ascii_control())
}
fn now_unix() -> i64 {
cookie::time::OffsetDateTime::now_utc().unix_timestamp()
}
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
use super::*;
use crate::net::NetworkPolicy;
fn spec(domain: &str, secure: bool) -> CookieSpec {
CookieSpec::new("n", "v", domain).unwrap().secure(secure)
}
#[test]
fn constructor_and_builders_own_invariants() {
assert!(matches!(
CookieSpec::new("", "value", "example.com"),
Err(InvalidCookie::EmptyName)
));
for (name, value) in [("bad;name", "value"), ("name", "bad;value"), ("bad\nname", "value")] {
assert!(matches!(
CookieSpec::new(name, value, "example.com"),
Err(InvalidCookie::IllegalChar)
));
}
assert!(matches!(
CookieSpec::new("name", "x".repeat(MAX_COOKIE_NAME_VALUE_BYTES), "example.com"),
Err(InvalidCookie::TooLarge)
));
assert!(matches!(
CookieSpec::new("name", "value", "not a host/"),
Err(InvalidCookie::InvalidHost)
));
let normalized = CookieSpec::new("name", "value", "EXAMPLE.COM")
.unwrap()
.path("account")
.unwrap();
assert_eq!(
(normalized.host, normalized.path.as_str()),
(Host::Domain("example.com".into()), "/")
);
assert!(matches!(
spec("example.com", false).path(format!("/{}", "x".repeat(MAX_COOKIE_PATH_BYTES))),
Err(InvalidCookie::InvalidPath)
));
}
#[test]
fn debug_redacts_value() {
let cookie = CookieSpec::new("name", "SUPERSECRET", "example.com").unwrap();
let debug = format!("{cookie:?}");
assert!(debug.contains("<redacted>") && !debug.contains("SUPERSECRET"));
}
#[test]
fn netscape_line_pair_uses_exact_curl_format() {
let session = CookieSpec::new("sid", "abc123", "app.example.com").unwrap();
let persistent = CookieSpec::new("auth", "secret", "example.com")
.unwrap()
.path("/account")
.unwrap()
.expires_at(2_000_000_000)
.secure(true)
.http_only(true)
.include_subdomains(true);
let loopback = CookieSpec::new("v6", "x", "::1").unwrap();
let mut output = Vec::new();
session.write_netscape(&mut output).unwrap();
persistent.write_netscape(&mut output).unwrap();
loopback.write_netscape(&mut output).unwrap();
assert_eq!(
output,
concat!(
"app.example.com\tFALSE\t/\tFALSE\t0\tsid\tabc123\n",
"#HttpOnly_.example.com\tTRUE\t/account\tTRUE\t2000000000\tauth\tsecret\n",
"::1\tFALSE\t/\tFALSE\t0\tv6\tx\n",
)
.as_bytes()
);
let parsed = std::str::from_utf8(&output)
.unwrap()
.lines()
.map(|line| CookieSpec::parse_netscape(line).unwrap().unwrap())
.collect::<Vec<_>>();
assert_eq!(parsed, [session, persistent, loopback]);
}
#[test]
fn netscape_parser_handles_non_rows_and_boundaries() {
assert_eq!(CookieSpec::parse_netscape("").unwrap(), None);
assert_eq!(CookieSpec::parse_netscape("# comment").unwrap(), None);
let parsed = CookieSpec::parse_netscape("#HttpOnly_.example.com\tFALSE\t/\tFALSE\t0\tn\tv")
.unwrap()
.unwrap();
assert_eq!(parsed.host, Host::<String>::Domain("example.com".into()));
assert!(parsed.http_only && parsed.host_only);
let empty_value = CookieSpec::parse_netscape("example.com\tFALSE\t/\tFALSE\t0\tn")
.unwrap()
.unwrap();
assert_eq!((empty_value.name.as_str(), empty_value.value.as_str()), ("n", ""));
assert!(matches!(
CookieSpec::parse_netscape("example.com\tFALSE\t/\tFALSE\t0"),
Err(NetscapeLineError::FieldCount { found: 5 })
));
}
#[test]
fn load_fold_replaces_same_key_and_expiry_deletes() {
let text = concat!(
".example.com\tTRUE\t/\tFALSE\t0\tsid\told\n",
"example.com\tFALSE\t/\tTRUE\t0\tsid\tnew\n",
"example.com\tFALSE\t/other\tFALSE\t0\tkeep\tv\n",
"example.com\tFALSE\t/\tFALSE\t1\tsid\tdeleted\n",
);
let cookies = fold_netscape(text).unwrap();
assert_eq!(
cookies,
[CookieSpec::new("keep", "v", "example.com")
.unwrap()
.path("/other")
.unwrap()]
);
}
#[test]
fn load_fold_rejects_too_many_unique_cookies() {
let mut text = String::new();
for index in 0..=MAX_COOKIES {
writeln!(text, "x.com\tFALSE\t/\tFALSE\t0\tn{index}\tv").unwrap();
}
assert!(matches!(fold_netscape(&text), Err(LoadError::TooMany(_))));
}
#[test]
fn from_wire_rejects_more_than_max_cookies() {
let cookie = CookieSpec::new("n", "v", "example.com").unwrap();
let wires = (0..=MAX_COOKIES).map(|_| CookieWire::from(cookie.clone())).collect();
assert!(matches!(from_wire(wires), Err(WireError::TooMany(_))));
}
#[test]
fn save_replaces_atomically_writes_empty_header_and_round_trips() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("cookies.txt");
std::fs::write(&path, "old contents").unwrap();
let expected = vec![spec("example.com", true).include_subdomains(true)];
save_cookies(&path, &expected).unwrap();
assert_eq!(load_cookies(&path).unwrap(), expected);
save_cookies(&path, &[]).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), NETSCAPE_HEADER);
std::fs::write(
&path,
format!("\u{FEFF}{NETSCAPE_HEADER}example.com\tFALSE\t/\tFALSE\t0\tbom\tv\n"),
)
.unwrap();
assert_eq!(load_cookies(&path).unwrap().len(), 1);
}
#[cfg(unix)]
#[test]
fn save_sets_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("cookies.txt");
save_cookies(&path, &[]).unwrap();
assert_eq!(std::fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
fn load_errors_carry_path_and_cause() {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(b"# comment\nwrong\n").unwrap();
assert_eq!(
load_cookies(file.path()).unwrap_err().report(),
format!(
"failed to load cookies from {}: line 2: expected 7 tab-separated fields, found 1",
file.path().display()
)
);
let missing = file.path().with_extension("missing");
assert!(
load_cookies(&missing)
.unwrap_err()
.report()
.starts_with(&format!("failed to load cookies from {}: ", missing.display()))
);
}
#[test]
fn from_cookie_maps_scope_expiry_and_ipv6_host() {
let queried = Url::parse("https://www.example.com/account").unwrap();
let expiry = cookie::time::OffsetDateTime::from_unix_timestamp(2_000_000_000).unwrap();
let host_cookie = cookie::Cookie::build(("sid", "secret"))
.domain("www.example.com")
.path("/account")
.expires(expiry)
.build();
let host_spec = CookieSpec::from_cookie(&host_cookie, &queried, true).unwrap();
assert!(host_spec.host_only);
assert_eq!(host_spec.expires, Some(2_000_000_000));
assert!(
!CookieSpec::from_cookie(&host_cookie, &queried, false)
.unwrap()
.host_only
);
let domain_cookie = cookie::Cookie::build(("sid", "secret")).domain(".example.com").build();
assert!(
!CookieSpec::from_cookie(&domain_cookie, &queried, false)
.unwrap()
.host_only
);
let ipv6 = Url::parse("http://[::1]:8080/path").unwrap();
let ipv6_cookie = cookie::Cookie::new("sid", "secret");
assert_eq!(
CookieSpec::from_cookie(&ipv6_cookie, &ipv6, true).unwrap().host,
Host::<String>::Ipv6("::1".parse().unwrap())
);
assert_eq!(
CookieSpec::from_cookie(&cookie::Cookie::new("", "secret"), &queried, true),
Err(InvalidCookie::EmptyName)
);
}
#[test]
fn wire_conversion_preserves_protocol_fields_and_rejects_invalid_fields() {
let expected = CookieSpec::new("n", "v", "example.com")
.unwrap()
.path("/account")
.unwrap()
.expires_at(2_000_000_000)
.secure(true)
.http_only(true)
.include_subdomains(true);
assert_eq!(
CookieSpec::try_from(CookieWire::from(expected.clone())).unwrap(),
expected
);
let invalid = CookieWire {
name: "bad;name".into(),
value: "secret".into(),
domain: "example.com".into(),
path: "/".into(),
expires: None,
secure: false,
http_only: false,
include_subdomains: false,
};
assert!(matches!(CookieSpec::try_from(invalid), Err(InvalidCookie::IllegalChar)));
}
#[test]
fn request_header_preserves_retrieval_order_scope_and_limits() {
let root = CookieSpec::new("root", "v", "example.com").unwrap();
let first = CookieSpec::new("first", "v", "example.com")
.unwrap()
.path("/account")
.unwrap();
let second = CookieSpec::new("second", "v", "example.com")
.unwrap()
.path("/account")
.unwrap();
let domain = CookieSpec::new("domain", "v", "example.com")
.unwrap()
.include_subdomains(true);
let target = Url::parse("https://www.example.com/account/report.pdf").unwrap();
assert_eq!(
request_header(&target, &[domain]).unwrap().to_str().unwrap(),
"domain=v"
);
let exact = Url::parse("https://example.com/account/report.pdf").unwrap();
assert_eq!(
request_header(&exact, &[first, second, root.clone()])
.unwrap()
.to_str()
.unwrap(),
"first=v; second=v; root=v"
);
assert_eq!(
request_header(&exact, &[root, spec("example.com", false).path("/account").unwrap()])
.unwrap()
.to_str()
.unwrap(),
"n=v; root=v"
);
let large = CookieSpec::new("a", "x".repeat(MAX_COOKIE_NAME_VALUE_BYTES - 1), "example.com").unwrap();
let header = request_header(&exact, &[large.clone(), large]).unwrap();
assert!(!header.to_str().unwrap().contains("; a="));
}
#[test]
fn request_header_handles_idna_ip_and_secure_loopback() {
let target = Url::parse("http://xn--bcher-kva.example/report.pdf").unwrap();
assert_eq!(
request_header(&target, &[spec("bücher.example", false)])
.unwrap()
.to_str()
.unwrap(),
"n=v"
);
let ip = Url::parse("http://127.0.0.1/report.pdf").unwrap();
assert!(request_header(&ip, &[spec("0.0.1", false).include_subdomains(true)]).is_none());
assert_eq!(
request_header(&ip, &[spec("127.0.0.1", true)])
.unwrap()
.to_str()
.unwrap(),
"n=v"
);
let subdomain = Url::parse("http://www.localhost/").unwrap();
assert!(request_header(&subdomain, &[spec("www.localhost", true)]).is_some());
}
#[test]
fn seedable_preserves_cookie_origin_rules() {
let public = Url::parse("https://example.com/").unwrap();
assert_eq!(seedable(&public, &[spec("app.example.com", false)]).len(), 1);
assert!(seedable(&public, &[spec("evil.com", false)]).is_empty());
let private = Url::parse("http://[::1]/").unwrap();
let cookie = spec("::1", false);
assert!(cookie.jar_entry(&private, NetworkPolicy::STRICT).is_none());
assert_eq!(
cookie.jar_entry(&private, NetworkPolicy::PERMISSIVE).unwrap().0.host(),
Some(Host::Ipv6("::1".parse().unwrap()))
);
}
}