mirror of
https://github.com/lexmount/moli.git
synced 2026-10-07 00:01:00 +00:00
1004 lines
34 KiB
Rust
1004 lines
34 KiB
Rust
use crate::cookie_domain::CookieDomain;
|
|
use crate::cookie_expiration::CookieExpiration;
|
|
use crate::cookie_path::CookiePath;
|
|
|
|
use crate::utils::{is_http_scheme, is_secure};
|
|
use cookie::{Cookie as RawCookie, CookieBuilder as RawCookieBuilder, ParseError};
|
|
use std::borrow::Cow;
|
|
use std::convert::TryFrom;
|
|
use std::fmt;
|
|
use std::ops::Deref;
|
|
use url::Url;
|
|
|
|
pub use cookie::CookiePriority;
|
|
|
|
/// Browser-computed partition key attached to a `Partitioned` cookie.
|
|
///
|
|
/// A normal key is the schemeful top-level site plus Chromium's ancestor-chain
|
|
/// bit. Opaque top-level sites use a transient nonce so unrelated opaque
|
|
/// browsing contexts cannot share cookies accidentally.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum CookiePartitionKey {
|
|
Site {
|
|
top_level_site: String,
|
|
has_cross_site_ancestor: bool,
|
|
},
|
|
Opaque {
|
|
nonce: u64,
|
|
has_cross_site_ancestor: bool,
|
|
},
|
|
}
|
|
|
|
impl CookiePartitionKey {
|
|
pub fn site(top_level_site: String, has_cross_site_ancestor: bool) -> Self {
|
|
Self::Site {
|
|
top_level_site,
|
|
has_cross_site_ancestor,
|
|
}
|
|
}
|
|
|
|
pub fn opaque(nonce: u64, has_cross_site_ancestor: bool) -> Self {
|
|
Self::Opaque {
|
|
nonce,
|
|
has_cross_site_ancestor,
|
|
}
|
|
}
|
|
|
|
pub fn top_level_site(&self) -> Option<&str> {
|
|
match self {
|
|
Self::Site { top_level_site, .. } => Some(top_level_site),
|
|
Self::Opaque { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub fn has_cross_site_ancestor(&self) -> bool {
|
|
match self {
|
|
Self::Site {
|
|
has_cross_site_ancestor,
|
|
..
|
|
}
|
|
| Self::Opaque {
|
|
has_cross_site_ancestor,
|
|
..
|
|
} => *has_cross_site_ancestor,
|
|
}
|
|
}
|
|
|
|
pub fn opaque_nonce(&self) -> Option<u64> {
|
|
match self {
|
|
Self::Site { .. } => None,
|
|
Self::Opaque { nonce, .. } => Some(*nonce),
|
|
}
|
|
}
|
|
|
|
pub fn is_opaque(&self) -> bool {
|
|
matches!(self, Self::Opaque { .. })
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum CookieSourceScheme {
|
|
#[default]
|
|
Unset,
|
|
NonSecure,
|
|
Secure,
|
|
}
|
|
|
|
impl CookieSourceScheme {
|
|
pub fn from_url(url: &Url) -> Self {
|
|
if matches!(url.scheme(), "https" | "wss") {
|
|
Self::Secure
|
|
} else {
|
|
Self::NonSecure
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Error {
|
|
/// Cookie had attribute HttpOnly but was received from a request-uri which was not an http
|
|
/// scheme
|
|
NonHttpScheme,
|
|
/// Cookie had attribute Secure but was received from a non-secure context
|
|
SecureOnly,
|
|
/// Cookie did not specify domain but was received from non-relative-scheme request-uri from
|
|
/// which host could not be determined
|
|
NonRelativeScheme,
|
|
/// Cookie received from a request-uri that does not domain-match
|
|
DomainMismatch,
|
|
/// `SameSite=None` cookies must also be marked Secure
|
|
SameSiteNoneRequiresSecure,
|
|
/// Cookie violated one of the browser prefix rules
|
|
PrefixViolation,
|
|
/// An insecure write attempted to overlap an existing secure cookie
|
|
SecureOverlay,
|
|
/// The combined cookie name and value exceed the browser compatibility
|
|
/// limit this fork models
|
|
NameValueTooLarge,
|
|
/// A `Partitioned` cookie was written without a browser-computed partition
|
|
/// key.
|
|
PartitionedMissingPartitionKey,
|
|
/// A `Partitioned` cookie did not also specify `Secure`.
|
|
PartitionedRequiresSecure,
|
|
/// Compatibility-only rejection retained for callers that still deserialize
|
|
/// an older result taxonomy.
|
|
UnsupportedPartitioned,
|
|
/// The cookie store could not make room for the new cookie under the
|
|
/// configured quota and eviction policy
|
|
StorageFull,
|
|
/// Cookie is Expired
|
|
Expired,
|
|
/// `cookie::Cookie` Parse error
|
|
Parse,
|
|
#[cfg(feature = "public_suffix")]
|
|
/// Cookie specified a public suffix domain-attribute that does not match the canonicalized
|
|
/// request-uri host
|
|
PublicSuffix,
|
|
/// Tried to use a CookieDomain variant of `Empty` or `NotPresent` in a context requiring a Domain value
|
|
UnspecifiedDomain,
|
|
}
|
|
|
|
impl std::error::Error for Error {}
|
|
|
|
impl fmt::Display for Error {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
f,
|
|
"{}",
|
|
match *self {
|
|
Error::NonHttpScheme =>
|
|
"request-uri is not an http scheme but HttpOnly attribute set",
|
|
Error::SecureOnly => "request-uri is not secure but Secure attribute set",
|
|
Error::NonRelativeScheme => {
|
|
"request-uri is not a relative scheme; cannot determine host"
|
|
}
|
|
Error::DomainMismatch => "request-uri does not domain-match the cookie",
|
|
Error::SameSiteNoneRequiresSecure =>
|
|
"SameSite=None cookies must also specify Secure",
|
|
Error::PrefixViolation => "cookie violates a protected prefix rule",
|
|
Error::SecureOverlay => "insecure cookie cannot overlay an existing secure cookie",
|
|
Error::NameValueTooLarge => "cookie name and value exceed the supported size limit",
|
|
Error::PartitionedMissingPartitionKey =>
|
|
"partitioned cookie write is missing a partition key",
|
|
Error::PartitionedRequiresSecure => "partitioned cookies must also specify Secure",
|
|
Error::UnsupportedPartitioned => "partitioned cookie input is unsupported",
|
|
Error::StorageFull => "cookie store is at capacity and could not evict a cookie",
|
|
Error::Expired => "attempted to utilize an Expired Cookie",
|
|
Error::Parse => "unable to parse string as cookie::Cookie",
|
|
#[cfg(feature = "public_suffix")]
|
|
Error::PublicSuffix => "domain-attribute value is a public suffix",
|
|
Error::UnspecifiedDomain => "domain-attribute is not specified",
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// cookie::Cookie::parse returns Result<Cookie, ()>
|
|
impl From<ParseError> for Error {
|
|
fn from(_: ParseError) -> Error {
|
|
Error::Parse
|
|
}
|
|
}
|
|
|
|
pub type CookieResult<'a> = Result<Cookie<'a>, Error>;
|
|
|
|
/// Structured input for constructing a canonical cookie without first
|
|
/// serializing a raw `Set-Cookie` string.
|
|
///
|
|
/// This is intended for privileged/browser-side mutation paths such as DevTools,
|
|
/// where the caller already has parsed cookie fields and should not need to
|
|
/// reassemble an HTTP header just to enter the core.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CanonicalCookieInput {
|
|
pub name: String,
|
|
pub value: String,
|
|
pub domain: String,
|
|
pub host_only: bool,
|
|
pub path: String,
|
|
pub secure: bool,
|
|
pub http_only: bool,
|
|
pub same_site: Option<cookie::SameSite>,
|
|
pub expires: CookieExpiration,
|
|
pub partition_key: Option<CookiePartitionKey>,
|
|
pub priority: Option<CookiePriority>,
|
|
pub source_scheme: CookieSourceScheme,
|
|
pub source_port: i32,
|
|
}
|
|
|
|
/// A cookie conforming more closely to [IETF RFC6265](https://datatracker.ietf.org/doc/html/rfc6265)
|
|
#[derive(PartialEq, Clone, Debug)]
|
|
pub struct Cookie<'a> {
|
|
/// The parsed Set-Cookie data
|
|
raw_cookie: RawCookie<'a>,
|
|
/// The Path attribute from a Set-Cookie header or the default-path as
|
|
/// determined from
|
|
/// the request-uri
|
|
pub path: CookiePath,
|
|
/// The Domain attribute from a Set-Cookie header, or a HostOnly variant if no
|
|
/// non-empty Domain attribute
|
|
/// found
|
|
pub domain: CookieDomain,
|
|
/// For a persistent Cookie (see [IETF RFC6265 Section
|
|
/// 5.3](https://datatracker.ietf.org/doc/html/rfc6265#section-5.3)),
|
|
/// the expiration time as defined by the Max-Age or Expires attribute,
|
|
/// otherwise SessionEnd,
|
|
/// indicating a non-persistent `Cookie` that should expire at the end of the
|
|
/// session
|
|
pub expires: CookieExpiration,
|
|
/// Monotonic creation order for the canonical cookie entry.
|
|
///
|
|
/// This is the tie-break used by browser-style request projection after
|
|
/// path length, and it must survive updates to an existing cookie so
|
|
/// higher layers do not need their own parallel ordering state.
|
|
pub(crate) creation_index: u64,
|
|
/// Monotonic last-access order for the canonical cookie entry.
|
|
///
|
|
/// Browser-style eviction uses this as the primary recency signal, so
|
|
/// reads and writes update one canonical counter in the core instead of
|
|
/// syncing wrapper-local metadata.
|
|
pub(crate) last_access_index: u64,
|
|
/// Declared `Priority` metadata from the source cookie, if the attribute was
|
|
/// explicitly present.
|
|
pub(crate) priority: Option<CookiePriority>,
|
|
/// Metadata describing the source scheme of the write that created this cookie.
|
|
pub(crate) source_scheme: CookieSourceScheme,
|
|
/// Metadata describing the source port of the write that created this cookie.
|
|
pub(crate) source_port: i32,
|
|
/// Partition identity for a `Partitioned` cookie. `None` denotes an
|
|
/// unpartitioned cookie.
|
|
pub(crate) partition_key: Option<CookiePartitionKey>,
|
|
}
|
|
|
|
impl<'a> Cookie<'a> {
|
|
/// Whether this `Cookie` should be included for `request_url`
|
|
pub fn matches(&self, request_url: &Url) -> bool {
|
|
self.path.matches(request_url)
|
|
&& self.domain.matches(request_url)
|
|
&& (!self.raw_cookie.secure().unwrap_or(false) || is_secure(request_url))
|
|
&& (!self.raw_cookie.http_only().unwrap_or(false) || is_http_scheme(request_url))
|
|
}
|
|
|
|
/// Should this `Cookie` be persisted across sessions?
|
|
pub fn is_persistent(&self) -> bool {
|
|
match self.expires {
|
|
CookieExpiration::AtUtc(_) => true,
|
|
CookieExpiration::SessionEnd => false,
|
|
}
|
|
}
|
|
|
|
/// Expire this cookie
|
|
pub fn expire(&mut self) {
|
|
self.expires = CookieExpiration::from(0u64);
|
|
}
|
|
|
|
/// Return whether the `Cookie` is expired *now*
|
|
pub fn is_expired(&self) -> bool {
|
|
self.expires.is_expired()
|
|
}
|
|
|
|
pub fn creation_index(&self) -> u64 {
|
|
self.creation_index
|
|
}
|
|
|
|
pub fn last_access_index(&self) -> u64 {
|
|
self.last_access_index
|
|
}
|
|
|
|
pub fn priority(&self) -> Option<CookiePriority> {
|
|
self.priority
|
|
}
|
|
|
|
/// Returns the eviction priority, defaulting omitted `Priority` to `Medium`.
|
|
pub fn effective_priority(&self) -> CookiePriority {
|
|
self.priority.unwrap_or(CookiePriority::Medium)
|
|
}
|
|
|
|
pub fn source_scheme(&self) -> CookieSourceScheme {
|
|
self.source_scheme
|
|
}
|
|
|
|
pub fn source_port(&self) -> i32 {
|
|
self.source_port
|
|
}
|
|
|
|
pub fn partition_key(&self) -> Option<&CookiePartitionKey> {
|
|
self.partition_key.as_ref()
|
|
}
|
|
|
|
pub(crate) fn set_creation_index(&mut self, creation_index: u64) {
|
|
self.creation_index = creation_index;
|
|
}
|
|
|
|
pub(crate) fn touch_with_access_index(&mut self, access_index: u64) {
|
|
self.last_access_index = access_index;
|
|
}
|
|
|
|
pub fn set_priority(&mut self, priority: CookiePriority) {
|
|
self.priority = Some(priority);
|
|
}
|
|
|
|
pub fn set_source_metadata(&mut self, source_scheme: CookieSourceScheme, source_port: i32) {
|
|
self.source_scheme = source_scheme;
|
|
self.source_port = source_port;
|
|
}
|
|
|
|
pub(crate) fn set_partition_key(&mut self, partition_key: Option<CookiePartitionKey>) {
|
|
self.partition_key = partition_key;
|
|
}
|
|
|
|
/// Indicates if the `Cookie` expires as of `utc_tm`.
|
|
pub fn expires_by(&self, utc_tm: &time::OffsetDateTime) -> bool {
|
|
self.expires.expires_by(utc_tm)
|
|
}
|
|
|
|
/// Parses a new `cookie_store::Cookie` from `cookie_str`.
|
|
pub fn parse<S>(cookie_str: S, request_url: &Url) -> CookieResult<'a>
|
|
where
|
|
S: Into<Cow<'a, str>>,
|
|
{
|
|
Cookie::try_from_raw_cookie(&RawCookie::parse(cookie_str)?, request_url)
|
|
}
|
|
|
|
/// Create a canonical cookie from structured fields.
|
|
///
|
|
/// This keeps raw-header assembly inside the core so higher layers can
|
|
/// provide parsed browser-facing DTOs without reimplementing cookie syntax
|
|
/// details.
|
|
pub fn try_from_canonical_input(
|
|
input: CanonicalCookieInput,
|
|
request_url: &Url,
|
|
) -> CookieResult<'static> {
|
|
let mut builder = RawCookieBuilder::new(input.name, input.value).path(input.path);
|
|
if !input.host_only {
|
|
builder = builder.domain(input.domain.clone());
|
|
}
|
|
if input.secure {
|
|
builder = builder.secure(true);
|
|
}
|
|
if input.http_only {
|
|
builder = builder.http_only(true);
|
|
}
|
|
if input.partition_key.is_some() {
|
|
builder = builder.partitioned(true);
|
|
}
|
|
if let Some(same_site) = input.same_site {
|
|
builder = builder.same_site(same_site);
|
|
}
|
|
if let CookieExpiration::AtUtc(expires) = input.expires {
|
|
builder = builder.expires(expires);
|
|
}
|
|
|
|
let mut cookie = Cookie::try_from_raw_cookie(&builder.build(), request_url)?.into_owned();
|
|
if input.host_only {
|
|
cookie.domain =
|
|
match CookieDomain::try_from(input.domain.as_str()).map_err(|_| Error::Parse)? {
|
|
CookieDomain::Suffix(domain) => CookieDomain::HostOnly(domain),
|
|
CookieDomain::Empty if request_url.scheme() == "file" => {
|
|
CookieDomain::HostOnly(String::new())
|
|
}
|
|
CookieDomain::Empty | CookieDomain::NotPresent | CookieDomain::HostOnly(_) => {
|
|
return Err(Error::Parse);
|
|
}
|
|
};
|
|
}
|
|
cookie.priority = input.priority;
|
|
cookie.partition_key = input.partition_key;
|
|
cookie.set_source_metadata(input.source_scheme, input.source_port);
|
|
Ok(cookie)
|
|
}
|
|
|
|
/// Create a new `cookie_store::Cookie` from a `cookie::Cookie` (from the `cookie` crate)
|
|
/// received from `request_url`.
|
|
pub fn try_from_raw_cookie(raw_cookie: &RawCookie<'a>, request_url: &Url) -> CookieResult<'a> {
|
|
if raw_cookie.http_only().unwrap_or(false) && !is_http_scheme(request_url) {
|
|
// If the cookie was received from a "non-HTTP" API and the
|
|
// cookie's http-only-flag is set, abort these steps and ignore the
|
|
// cookie entirely.
|
|
return Err(Error::NonHttpScheme);
|
|
}
|
|
|
|
let domain = match CookieDomain::try_from(raw_cookie) {
|
|
// 6. If the domain-attribute is non-empty:
|
|
Ok(d @ CookieDomain::Suffix(_)) => {
|
|
if !d.matches(request_url) {
|
|
// If the canonicalized request-host does not domain-match the
|
|
// domain-attribute:
|
|
// Ignore the cookie entirely and abort these steps.
|
|
Err(Error::DomainMismatch)
|
|
} else {
|
|
// Otherwise:
|
|
// Set the cookie's host-only-flag to false.
|
|
// Set the cookie's domain to the domain-attribute.
|
|
Ok(d)
|
|
}
|
|
}
|
|
Err(_) => Err(Error::Parse),
|
|
// Otherwise:
|
|
// Set the cookie's host-only-flag to true.
|
|
// Set the cookie's domain to the canonicalized request-host.
|
|
_ => CookieDomain::host_only(request_url),
|
|
}?;
|
|
|
|
let path = raw_cookie
|
|
.path()
|
|
.as_ref()
|
|
.and_then(|p| CookiePath::parse(p))
|
|
.unwrap_or_else(|| CookiePath::default_path(request_url));
|
|
|
|
// per RFC6265, Max-Age takes precedence, then Expires, otherwise is Session
|
|
// only
|
|
let expires = if let Some(max_age) = raw_cookie.max_age() {
|
|
CookieExpiration::from(max_age)
|
|
} else if let Some(expiration) = raw_cookie.expires() {
|
|
CookieExpiration::from(expiration)
|
|
} else {
|
|
CookieExpiration::SessionEnd
|
|
};
|
|
|
|
let priority = raw_cookie.priority();
|
|
|
|
Ok(Cookie {
|
|
raw_cookie: raw_cookie.clone(),
|
|
path,
|
|
expires,
|
|
domain,
|
|
creation_index: 0,
|
|
last_access_index: 0,
|
|
priority,
|
|
source_scheme: CookieSourceScheme::from_url(request_url),
|
|
source_port: request_url.port_or_known_default().map_or(-1, i32::from),
|
|
partition_key: None,
|
|
})
|
|
}
|
|
|
|
pub fn into_owned(self) -> Cookie<'static> {
|
|
Cookie {
|
|
raw_cookie: self.raw_cookie.into_owned(),
|
|
path: self.path,
|
|
domain: self.domain,
|
|
expires: self.expires,
|
|
creation_index: self.creation_index,
|
|
last_access_index: self.last_access_index,
|
|
priority: self.priority,
|
|
source_scheme: self.source_scheme,
|
|
source_port: self.source_port,
|
|
partition_key: self.partition_key,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Deref for Cookie<'a> {
|
|
type Target = RawCookie<'a>;
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.raw_cookie
|
|
}
|
|
}
|
|
|
|
impl<'a> From<Cookie<'a>> for RawCookie<'a> {
|
|
fn from(cookie: Cookie<'a>) -> RawCookie<'static> {
|
|
let mut builder =
|
|
RawCookieBuilder::new(cookie.name().to_owned(), cookie.value().to_owned());
|
|
|
|
// Max-Age is relative, will not have same meaning now, so only set `Expires`.
|
|
match cookie.expires {
|
|
CookieExpiration::AtUtc(utc_tm) => {
|
|
builder = builder.expires(utc_tm);
|
|
}
|
|
CookieExpiration::SessionEnd => {}
|
|
}
|
|
|
|
if cookie.path.is_from_path_attr() {
|
|
builder = builder.path(String::from(cookie.path));
|
|
}
|
|
|
|
if let CookieDomain::Suffix(s) = cookie.domain {
|
|
builder = builder.domain(s);
|
|
}
|
|
|
|
if let Some(priority) = cookie.priority {
|
|
builder = builder.priority(priority);
|
|
}
|
|
|
|
if cookie.partition_key.is_some() {
|
|
builder = builder.partitioned(true);
|
|
}
|
|
|
|
builder.build()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Cookie;
|
|
use crate::cookie_domain::CookieDomain;
|
|
use crate::cookie_expiration::CookieExpiration;
|
|
use cookie::Cookie as RawCookie;
|
|
use time::{Duration, OffsetDateTime};
|
|
use url::Url;
|
|
|
|
use crate::utils::test as test_utils;
|
|
|
|
fn cmp_domain(cookie: &str, url: &str, exp: CookieDomain) {
|
|
let ua = test_utils::make_cookie(cookie, url, None, None);
|
|
assert!(ua.domain == exp, "\n{ua:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_domain() {
|
|
let url = test_utils::url("http://example.com/foo/bar");
|
|
cmp_domain(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
CookieDomain::host_only(&url).expect("unable to parse domain"),
|
|
);
|
|
}
|
|
|
|
// per RFC6265:
|
|
// If the attribute-value is empty, the behavior is undefined. However,
|
|
// the user agent SHOULD ignore the cookie-av entirely.
|
|
#[test]
|
|
fn empty_domain() {
|
|
let url = test_utils::url("http://example.com/foo/bar");
|
|
cmp_domain(
|
|
"cookie1=value1; Domain=",
|
|
"http://example.com/foo/bar",
|
|
CookieDomain::host_only(&url).expect("unable to parse domain"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mismatched_domain() {
|
|
let ua = Cookie::parse(
|
|
"cookie1=value1; Domain=notmydomain.com",
|
|
&test_utils::url("http://example.com/foo/bar"),
|
|
);
|
|
assert!(ua.is_err(), "{ua:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn domains() {
|
|
fn domain_from(domain: &str, request_url: &str, is_some: bool) {
|
|
let cookie_str = format!("cookie1=value1; Domain={domain}");
|
|
let raw_cookie = RawCookie::parse(cookie_str).unwrap();
|
|
let cookie = Cookie::try_from_raw_cookie(&raw_cookie, &test_utils::url(request_url));
|
|
assert_eq!(is_some, cookie.is_ok())
|
|
}
|
|
// The user agent will reject cookies unless the Domain attribute
|
|
// specifies a scope for the cookie that would include the origin
|
|
// server. For example, the user agent will accept a cookie with a
|
|
// Domain attribute of "example.com" or of "foo.example.com" from
|
|
// foo.example.com, but the user agent will not accept a cookie with a
|
|
// Domain attribute of "bar.example.com" or of "baz.foo.example.com".
|
|
domain_from("example.com", "http://foo.example.com", true);
|
|
domain_from(".example.com", "http://foo.example.com", true);
|
|
domain_from("foo.example.com", "http://foo.example.com", true);
|
|
domain_from(".foo.example.com", "http://foo.example.com", true);
|
|
|
|
domain_from("oo.example.com", "http://foo.example.com", false);
|
|
domain_from("myexample.com", "http://foo.example.com", false);
|
|
domain_from("bar.example.com", "http://foo.example.com", false);
|
|
domain_from("baz.foo.example.com", "http://foo.example.com", false);
|
|
}
|
|
|
|
#[test]
|
|
fn httponly() {
|
|
let c = RawCookie::parse("cookie1=value1; HttpOnly").unwrap();
|
|
let url = Url::parse("ftp://example.com/foo/bar").unwrap();
|
|
let ua = Cookie::try_from_raw_cookie(&c, &url);
|
|
assert!(ua.is_err(), "{ua:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn raw_cookie_priority_is_projected_into_core_cookie() {
|
|
let raw_cookie = RawCookie::parse("sid=1; Path=/; Priority=High").unwrap();
|
|
let cookie =
|
|
Cookie::try_from_raw_cookie(&raw_cookie, &test_utils::url("https://example.com/"))
|
|
.expect("cookie should parse");
|
|
|
|
assert_eq!(cookie.priority(), Some(super::CookiePriority::High));
|
|
assert_eq!(cookie.effective_priority(), super::CookiePriority::High);
|
|
}
|
|
|
|
#[test]
|
|
fn raw_cookie_conversion_does_not_synthesize_default_priority_attribute() {
|
|
let cookie = Cookie::parse("sid=1; Path=/", &test_utils::url("https://example.com/"))
|
|
.expect("cookie should parse")
|
|
.into_owned();
|
|
let raw_cookie: RawCookie<'static> = cookie.into();
|
|
|
|
assert_eq!(raw_cookie.priority(), None);
|
|
assert!(!raw_cookie.to_string().contains("Priority="));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_cookie_conversion_preserves_explicit_medium_priority_attribute() {
|
|
let cookie = Cookie::parse(
|
|
"sid=1; Path=/; Priority=Medium",
|
|
&test_utils::url("https://example.com/"),
|
|
)
|
|
.expect("cookie should parse")
|
|
.into_owned();
|
|
let raw_cookie: RawCookie<'static> = cookie.into();
|
|
|
|
assert_eq!(raw_cookie.priority(), Some(cookie::CookiePriority::Medium));
|
|
assert!(raw_cookie.to_string().contains("Priority=Medium"));
|
|
}
|
|
|
|
#[test]
|
|
fn identical_domain() {
|
|
cmp_domain(
|
|
"cookie1=value1; Domain=example.com",
|
|
"http://example.com/foo/bar",
|
|
CookieDomain::Suffix(String::from("example.com")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identical_domain_leading_dot() {
|
|
cmp_domain(
|
|
"cookie1=value1; Domain=.example.com",
|
|
"http://example.com/foo/bar",
|
|
CookieDomain::Suffix(String::from("example.com")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identical_domain_two_leading_dots() {
|
|
cmp_domain(
|
|
"cookie1=value1; Domain=..example.com",
|
|
"http://..example.com/foo/bar",
|
|
CookieDomain::Suffix(String::from(".example.com")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn upper_case_domain() {
|
|
cmp_domain(
|
|
"cookie1=value1; Domain=EXAMPLE.com",
|
|
"http://example.com/foo/bar",
|
|
CookieDomain::Suffix(String::from("example.com")),
|
|
);
|
|
}
|
|
|
|
fn cmp_path(cookie: &str, url: &str, exp: &str) {
|
|
let ua = test_utils::make_cookie(cookie, url, None, None);
|
|
assert!(String::from(ua.path.clone()) == exp, "\n{ua:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_path() {
|
|
// no Path specified
|
|
cmp_path("cookie1=value1", "http://example.com/foo/bar/", "/foo/bar");
|
|
cmp_path("cookie1=value1", "http://example.com/foo/bar", "/foo");
|
|
cmp_path("cookie1=value1", "http://example.com/foo", "/");
|
|
cmp_path("cookie1=value1", "http://example.com/", "/");
|
|
cmp_path("cookie1=value1", "http://example.com", "/");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_path() {
|
|
// Path specified with empty value
|
|
cmp_path(
|
|
"cookie1=value1; Path=",
|
|
"http://example.com/foo/bar/",
|
|
"/foo/bar",
|
|
);
|
|
cmp_path(
|
|
"cookie1=value1; Path=",
|
|
"http://example.com/foo/bar",
|
|
"/foo",
|
|
);
|
|
cmp_path("cookie1=value1; Path=", "http://example.com/foo", "/");
|
|
cmp_path("cookie1=value1; Path=", "http://example.com/", "/");
|
|
cmp_path("cookie1=value1; Path=", "http://example.com", "/");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_path() {
|
|
// Invalid Path specified (first character not /)
|
|
cmp_path(
|
|
"cookie1=value1; Path=baz",
|
|
"http://example.com/foo/bar/",
|
|
"/foo/bar",
|
|
);
|
|
cmp_path(
|
|
"cookie1=value1; Path=baz",
|
|
"http://example.com/foo/bar",
|
|
"/foo",
|
|
);
|
|
cmp_path("cookie1=value1; Path=baz", "http://example.com/foo", "/");
|
|
cmp_path("cookie1=value1; Path=baz", "http://example.com/", "/");
|
|
cmp_path("cookie1=value1; Path=baz", "http://example.com", "/");
|
|
}
|
|
|
|
#[test]
|
|
fn path() {
|
|
// Path specified, single /
|
|
cmp_path(
|
|
"cookie1=value1; Path=/baz",
|
|
"http://example.com/foo/bar/",
|
|
"/baz",
|
|
);
|
|
// Path specified, multiple / (for valid attribute-value on path, take full
|
|
// string)
|
|
cmp_path(
|
|
"cookie1=value1; Path=/baz/",
|
|
"http://example.com/foo/bar/",
|
|
"/baz/",
|
|
);
|
|
}
|
|
|
|
// expiry-related tests
|
|
#[inline]
|
|
fn in_days(days: i64) -> OffsetDateTime {
|
|
OffsetDateTime::now_utc() + Duration::days(days)
|
|
}
|
|
#[inline]
|
|
fn in_minutes(mins: i64) -> OffsetDateTime {
|
|
OffsetDateTime::now_utc() + Duration::minutes(mins)
|
|
}
|
|
|
|
#[test]
|
|
fn max_age_bounds() {
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
Some(9223372036854776),
|
|
);
|
|
assert!(matches!(ua.expires, CookieExpiration::AtUtc(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn max_age() {
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
Some(60),
|
|
);
|
|
assert!(!ua.is_expired());
|
|
assert!(ua.expires_by(&in_minutes(2)));
|
|
}
|
|
|
|
#[test]
|
|
fn expired() {
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
Some(0u64),
|
|
);
|
|
assert!(ua.is_expired());
|
|
assert!(ua.expires_by(&in_days(-1)));
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1; Max-Age=0",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
None,
|
|
);
|
|
assert!(ua.is_expired());
|
|
assert!(ua.expires_by(&in_days(-1)));
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1; Max-Age=-1",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
None,
|
|
);
|
|
assert!(ua.is_expired());
|
|
assert!(ua.expires_by(&in_days(-1)));
|
|
}
|
|
|
|
#[test]
|
|
fn session_end() {
|
|
let ua =
|
|
test_utils::make_cookie("cookie1=value1", "http://example.com/foo/bar", None, None);
|
|
assert!(matches!(ua.expires, CookieExpiration::SessionEnd));
|
|
assert!(!ua.is_expired());
|
|
assert!(!ua.expires_by(&in_days(1)));
|
|
assert!(!ua.expires_by(&in_days(-1)));
|
|
}
|
|
|
|
#[test]
|
|
fn expires_tmrw_at_utc() {
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some(in_days(1)),
|
|
None,
|
|
);
|
|
assert!(!ua.is_expired());
|
|
assert!(ua.expires_by(&in_days(2)));
|
|
}
|
|
|
|
#[test]
|
|
fn expired_yest_at_utc() {
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some(in_days(-1)),
|
|
None,
|
|
);
|
|
assert!(ua.is_expired());
|
|
assert!(!ua.expires_by(&in_days(-2)));
|
|
}
|
|
|
|
#[test]
|
|
fn is_persistent() {
|
|
let ua =
|
|
test_utils::make_cookie("cookie1=value1", "http://example.com/foo/bar", None, None);
|
|
assert!(!ua.is_persistent()); // SessionEnd
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some(in_days(1)),
|
|
None,
|
|
);
|
|
assert!(ua.is_persistent()); // AtUtc from Expires
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some(in_days(1)),
|
|
Some(60),
|
|
);
|
|
assert!(ua.is_persistent()); // AtUtc from Max-Age
|
|
}
|
|
|
|
#[test]
|
|
fn max_age_overrides_expires() {
|
|
// Expires indicates expiration yesterday, but Max-Age indicates expiry in 1
|
|
// minute
|
|
let ua = test_utils::make_cookie(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some(in_days(-1)),
|
|
Some(60),
|
|
);
|
|
assert!(!ua.is_expired());
|
|
assert!(ua.expires_by(&in_minutes(2)));
|
|
}
|
|
|
|
// A request-path path-matches a given cookie-path if at least one of
|
|
// the following conditions holds:
|
|
// o The cookie-path and the request-path are identical.
|
|
// o The cookie-path is a prefix of the request-path, and the last
|
|
// character of the cookie-path is %x2F ("/").
|
|
// o The cookie-path is a prefix of the request-path, and the first
|
|
// character of the request-path that is not included in the cookie-
|
|
// path is a %x2F ("/") character.
|
|
#[test]
|
|
fn matches() {
|
|
fn do_match(exp: bool, cookie: &str, src_url: &str, request_url: Option<&str>) {
|
|
let ua = test_utils::make_cookie(cookie, src_url, None, None);
|
|
let request_url = request_url.unwrap_or(src_url);
|
|
assert!(
|
|
exp == ua.matches(&Url::parse(request_url).unwrap()),
|
|
"\n>> {:?}\nshould{}match\n>> {:?}\n",
|
|
ua,
|
|
if exp { " " } else { " NOT " },
|
|
request_url
|
|
);
|
|
}
|
|
fn is_match(cookie: &str, url: &str, request_url: Option<&str>) {
|
|
do_match(true, cookie, url, request_url);
|
|
}
|
|
fn is_mismatch(cookie: &str, url: &str, request_url: Option<&str>) {
|
|
do_match(false, cookie, url, request_url);
|
|
}
|
|
|
|
// match: request-path & cookie-path (defaulted from request-uri) identical
|
|
is_match("cookie1=value1", "http://example.com/foo/bar", None);
|
|
// mismatch: request-path & cookie-path do not match
|
|
is_mismatch(
|
|
"cookie1=value1",
|
|
"http://example.com/bus/baz/",
|
|
Some("http://example.com/foo/bar"),
|
|
);
|
|
is_mismatch(
|
|
"cookie1=value1; Path=/bus/baz",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
);
|
|
// match: cookie-path a prefix of request-path and last character of
|
|
// cookie-path is /
|
|
is_match(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some("http://example.com/foo/bar"),
|
|
);
|
|
is_match(
|
|
"cookie1=value1; Path=/foo/",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
);
|
|
// mismatch: cookie-path a prefix of request-path but last character of
|
|
// cookie-path is not /
|
|
// and first character of request-path not included in cookie-path is not /
|
|
is_mismatch(
|
|
"cookie1=value1",
|
|
"http://example.com/fo/",
|
|
Some("http://example.com/foo/bar"),
|
|
);
|
|
is_mismatch(
|
|
"cookie1=value1; Path=/fo",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
);
|
|
// match: cookie-path a prefix of request-path and first character of
|
|
// request-path
|
|
// not included in the cookie-path is /
|
|
is_match(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/",
|
|
Some("http://example.com/foo/bar"),
|
|
);
|
|
is_match(
|
|
"cookie1=value1; Path=/foo",
|
|
"http://example.com/foo/bar",
|
|
None,
|
|
);
|
|
// match: Path overridden to /, which matches all paths from the domain
|
|
is_match(
|
|
"cookie1=value1; Path=/",
|
|
"http://example.com/foo/bar",
|
|
Some("http://example.com/bus/baz"),
|
|
);
|
|
// mismatch: different domain
|
|
is_mismatch(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/",
|
|
Some("http://notmydomain.com/foo/bar"),
|
|
);
|
|
is_mismatch(
|
|
"cookie1=value1; Domain=example.com",
|
|
"http://foo.example.com/foo/",
|
|
Some("http://notmydomain.com/foo/bar"),
|
|
);
|
|
// match: secure protocol
|
|
is_match(
|
|
"cookie1=value1; Secure",
|
|
"http://example.com/foo/bar",
|
|
Some("https://example.com/foo/bar"),
|
|
);
|
|
// mismatch: non-secure protocol
|
|
is_mismatch(
|
|
"cookie1=value1; Secure",
|
|
"http://example.com/foo/bar",
|
|
Some("http://example.com/foo/bar"),
|
|
);
|
|
// match: no http restriction
|
|
is_match(
|
|
"cookie1=value1",
|
|
"http://example.com/foo/bar",
|
|
Some("ftp://example.com/foo/bar"),
|
|
);
|
|
// match: http protocol
|
|
is_match(
|
|
"cookie1=value1; HttpOnly",
|
|
"http://example.com/foo/bar",
|
|
Some("http://example.com/foo/bar"),
|
|
);
|
|
is_match(
|
|
"cookie1=value1; HttpOnly",
|
|
"http://example.com/foo/bar",
|
|
Some("HTTP://example.com/foo/bar"),
|
|
);
|
|
is_match(
|
|
"cookie1=value1; HttpOnly",
|
|
"http://example.com/foo/bar",
|
|
Some("https://example.com/foo/bar"),
|
|
);
|
|
// mismatch: http requried
|
|
is_mismatch(
|
|
"cookie1=value1; HttpOnly",
|
|
"http://example.com/foo/bar",
|
|
Some("ftp://example.com/foo/bar"),
|
|
);
|
|
is_mismatch(
|
|
"cookie1=value1; HttpOnly",
|
|
"http://example.com/foo/bar",
|
|
Some("data:nonrelativescheme"),
|
|
);
|
|
}
|
|
}
|