///|
/// Cookies kept per domain by default: the minimum RFC 6265 §6.1 asks of a
/// user agent.
let default_max_per_domain : Int32 = 50
///|
/// Cookies kept in a jar by default: the RFC 6265 §6.1 minimum.
let default_max_total : Int32 = 3000
///|
/// The longest a cookie lives, 400 days: a later `Expires` or larger
/// `Max-Age` is cut to it, as RFC 6265bis §5.5 and browsers do.
let max_lifetime_ms : Int64 = 400L * 86_400_000L
///|
/// A stored cookie and its place in creation order, which breaks ties
/// between cookies created or accessed in the same millisecond.
priv struct Entry {
mut cookie : Cookie
sequence : Int32
}
///|
/// An RFC 6265 cookie store for an HTTP client: `store` takes the
/// `Set-Cookie` headers of a response, `header_for` builds the `Cookie`
/// header of a request.
///
/// It is a client's jar, not a browser's: `HttpOnly` cookies are sent like
/// any other, and `SameSite` is kept but not enforced, since a client has no
/// notion of a cross-site request. Without the Public Suffix List it rejects
/// only a `Domain` of a single label (`com`, `localhost`); pass
/// `is_public_suffix` to reject more. An IP-address host only gets host-only
/// cookies. Hosts must arrive in ASCII form, so a `Domain` with other
/// characters is ignored.
///
/// ```mbt check
/// test {
/// let jar = @cookie.CookieJar::new(@mock.FakeClock::new())
/// jar.store(
/// @http.Headers::from_array([("Set-Cookie", "sid=1; Path=/; Secure")]),
/// "https://example.com/login",
/// )
/// assert_eq(jar.header_for("https://example.com/"), Some("sid=1"))
/// assert_eq(jar.header_for("http://example.com/"), None)
/// }
/// ```
pub struct CookieJar {
priv clock : &@clock.Clock
priv max_per_domain : Int32
priv max_total : Int32
priv is_public_suffix : (String) -> Bool
/// Entries keyed by their cookie's domain.
priv domains : Map[String, Array[Entry]]
priv mut total : Int32
priv mut next_sequence : Int32
}
///|
/// Creates an empty jar that reads the time from `clock`.
///
/// `max_per_domain` (default 50) and `max_total` (default 3000) bound how many
/// cookies it keeps; a cookie past either evicts expired cookies first, then
/// the least recently accessed (RFC 6265 §5.3). Negative limits count as 0.
/// `is_public_suffix` names further domains a cookie may not be set for, such
/// as a Public Suffix List lookup; single-label domains are always refused.
pub fn CookieJar::new(
clock : &@clock.Clock,
max_per_domain~ : Int32 = default_max_per_domain,
max_total~ : Int32 = default_max_total,
is_public_suffix~ : (String) -> Bool = _ => false,
) -> CookieJar {
{
clock,
max_per_domain: max_per_domain.max(0),
max_total: max_total.max(0),
is_public_suffix,
domains: Map([]),
total: 0,
next_sequence: 0,
}
}
///|
/// Stores every `Set-Cookie` of a response to `request_url`, in order.
/// Values the RFC 6265 algorithms ignore, and every value when the URL has no
/// host, are skipped.
pub fn CookieJar::store(
self : CookieJar,
headers : @http.Headers,
request_url : String,
) -> Unit {
guard parse_request_url(request_url) is Some(url) else { return }
let now_ms = self.clock.now_unix_ms()
for value in headers.get_all("set-cookie") {
self.store_at(url, value, now_ms)
}
}
///|
/// Returns the `Cookie` header value for a request to `request_url`, or
/// `None` when no cookie matches, and marks the cookies sent as accessed.
/// Longer paths come first, then earlier-created cookies (RFC 6265 §5.4).
pub fn CookieJar::header_for(self : CookieJar, request_url : String) -> String? {
guard parse_request_url(request_url) is Some(url) else { return None }
self.header_at(url, self.clock.now_unix_ms())
}
///|
/// The number of cookies stored, expired ones not yet evicted included.
pub fn CookieJar::length(self : CookieJar) -> Int32 {
self.total
}
///|
/// Iterates over a snapshot of the stored cookies, grouped by domain.
/// Expired cookies not yet evicted are included; see `remove_expired`.
pub fn CookieJar::iter(self : CookieJar) -> Iter[Cookie] {
let cookies = []
for _, entries in self.domains {
for entry in entries {
cookies.push(entry.cookie)
}
}
cookies.iter()
}
///|
/// Removes every cookie.
pub fn CookieJar::clear(self : CookieJar) -> Unit {
self.domains.clear()
self.total = 0
}
///|
/// Removes the cookies whose domain is `domain` or one of its subdomains.
pub fn CookieJar::clear_domain(self : CookieJar, domain : String) -> Unit {
let domain = domain.to_lower()
self.remove_where(cookie => domain_match(cookie.domain, domain))
}
///|
/// Removes the session cookies (those without `Expires` or `Max-Age`), as
/// RFC 6265 §5.3 does when a session ends.
pub fn CookieJar::clear_session(self : CookieJar) -> Unit {
self.remove_where(cookie => cookie.expires_ms is None)
}
///|
/// Removes the cookies that have expired by the clock's current time.
pub fn CookieJar::remove_expired(self : CookieJar) -> Unit {
let now_ms = self.clock.now_unix_ms()
self.remove_where(cookie => cookie.is_expired(now_ms))
}
///|
/// The RFC 6265 §5.3 storage model for one `Set-Cookie` value received at
/// `now_ms`, with the RFC 6265bis additions browsers apply: Secure cookies
/// only from secure origins, the `__Secure-` and `__Host-` prefixes, and no
/// insecure overwrite of a Secure cookie. Expired cookies are evicted first
/// (§5.3 asks for that whenever one exists), so none of these rules, the
/// replacement of a cookie or the caps ever consult one.
fn CookieJar::store_at(
self : CookieJar,
url : RequestUrl,
text : String,
now_ms : Int64,
) -> Unit {
guard parse_set_cookie(text) is Some(parsed) else { return }
self.remove_where(cookie => cookie.is_expired(now_ms))
guard self.cookie_domain(url.host, parsed.domain) is Some((domain, host_only)) else {
return
}
let path = match parsed.path {
Some(path) if path.has_prefix("/") => path
_ => default_path(url.path)
}
guard !parsed.secure || url.is_secure() else { return }
guard prefix_allows(parsed, host_only) else { return }
guard parsed.secure ||
url.is_secure() ||
!self.shadows_secure(parsed.name, domain, path) else {
return
}
let entries = match self.domains.get(domain) {
Some(entries) => entries
None => []
}
let mut creation_ms = now_ms
let mut sequence = self.next_sequence
for i, entry in entries {
if entry.cookie.name == parsed.name && entry.cookie.path == path {
creation_ms = entry.cookie.creation_ms
sequence = entry.sequence
entries.remove(i) |> ignore
self.total -= 1
break
}
}
let cookie = {
name: parsed.name,
value: parsed.value,
domain,
host_only,
path,
expires_ms: expiry(parsed, now_ms),
secure: parsed.secure,
http_only: parsed.http_only,
same_site: parsed.same_site,
creation_ms,
last_access_ms: now_ms,
}
if cookie.is_expired(now_ms) {
// An expired cookie only deletes the one it replaces.
if entries.is_empty() {
self.domains.remove(domain)
}
return
}
if sequence == self.next_sequence {
self.next_sequence += 1
}
entries.push({ cookie, sequence, })
self.domains.set(domain, entries)
self.total += 1
self.enforce_limits(domain)
}
///|
/// RFC 6265 §5.3 steps 4-6: the cookie's domain and host-only flag, or
/// `None` when the `Domain` attribute is not one `host` may set.
fn CookieJar::cookie_domain(
self : CookieJar,
host : String,
attribute : String?,
) -> (String, Bool)? {
guard attribute is Some(domain) else { return Some((host, true)) }
for c in domain {
guard c.to_int() < 0x80 else { return None }
}
let domain = canonical_ip_literal(domain)
if is_single_label(domain) ||
(self.is_public_suffix)(domain) ||
is_ip_host(host) {
// A public suffix, or any Domain on an IP host, is only accepted as the
// host itself, and then as host-only.
if domain == host {
Some((host, true))
} else {
None
}
} else if domain_match(host, domain) {
Some((domain, false))
} else {
None
}
}
///|
/// RFC 6265bis §4.1.3: a `__Secure-` cookie must be Secure; a `__Host-`
/// cookie must also be host-only with a `Path` attribute whose value is `/`
/// (not one that falls back to a default path of `/`). Prefixes match
/// ignoring ASCII case, as browsers do.
fn prefix_allows(parsed : SetCookie, host_only : Bool) -> Bool {
let name = parsed.name.to_lower()
if name.has_prefix("__secure-") {
parsed.secure
} else if name.has_prefix("__host-") {
parsed.secure && host_only && parsed.path == Some("/")
} else {
true
}
}
///|
/// RFC 6265bis §5.7 step 16: whether an insecure cookie would overwrite or
/// shadow a stored Secure cookie of the same name on a related domain and an
/// enclosing path.
fn CookieJar::shadows_secure(
self : CookieJar,
name : String,
domain : String,
path : String,
) -> Bool {
for stored, entries in self.domains {
guard domain_match(domain, stored) || domain_match(stored, domain) else {
continue
}
for entry in entries {
let cookie = entry.cookie
if cookie.secure && cookie.name == name && path_match(path, cookie.path) {
return true
}
}
}
false
}
///|
/// RFC 6265 §5.3 step 3: `Max-Age` wins over `Expires`; a nonpositive one
/// expires the cookie at once. Either is cut to `max_lifetime_ms`.
fn expiry(parsed : SetCookie, now_ms : Int64) -> Int64? {
let latest = now_ms + max_lifetime_ms
match parsed.max_age {
Some(seconds) if seconds <= 0L => Some(@int64.MIN_VALUE)
Some(seconds) =>
Some(
if seconds > max_lifetime_ms / 1000L {
latest
} else {
now_ms + seconds * 1000L
},
)
None => parsed.expires_ms.map(expires => expires.min(latest))
}
}
///|
/// Applies the bounds after a cookie joins `domain`'s entries. `store_at`
/// has already evicted the expired cookies (RFC 6265 §5.3's first choice),
/// so the least recently accessed go.
fn CookieJar::enforce_limits(self : CookieJar, domain : String) -> Unit {
while self.domains.get(domain) is Some(entries) &&
entries.length() > self.max_per_domain {
self.evict_oldest(only=Some(domain))
}
while self.total > self.max_total {
self.evict_oldest(only=None)
}
}
///|
/// Removes the least recently accessed cookie, of one domain or of all.
fn CookieJar::evict_oldest(self : CookieJar, only~ : String?) -> Unit {
let mut oldest : (String, Int32, Entry)? = None
for domain, entries in self.domains {
if only is Some(only) && only != domain {
continue
}
for i, entry in entries {
match oldest {
Some((_, _, best)) if !accessed_before(entry, best) => ()
_ => oldest = Some((domain, i, entry))
}
}
}
if oldest is Some((domain, i, _)) {
self.remove_at(domain, i)
}
}
///|
/// Eviction order: earlier last access first, then earlier creation.
fn accessed_before(a : Entry, b : Entry) -> Bool {
a.cookie.last_access_ms < b.cookie.last_access_ms ||
(
a.cookie.last_access_ms == b.cookie.last_access_ms &&
a.sequence < b.sequence
)
}
///|
fn CookieJar::remove_at(self : CookieJar, domain : String, i : Int32) -> Unit {
guard self.domains.get(domain) is Some(entries) else { return }
entries.remove(i) |> ignore
self.total -= 1
if entries.is_empty() {
self.domains.remove(domain)
}
}
///|
fn CookieJar::remove_where(self : CookieJar, drop : (Cookie) -> Bool) -> Unit {
let emptied = []
for domain, entries in self.domains {
let before = entries.length()
entries.retain(entry => !drop(entry.cookie))
self.total -= before - entries.length()
if entries.is_empty() {
emptied.push(domain)
}
}
for domain in emptied {
self.domains.remove(domain)
}
}
///|
/// RFC 6265 §5.4 at `now_ms`: the matching cookies' `name=value` pairs,
/// longer paths first, then earlier creation. Expired cookies met on the way
/// are evicted; the ones sent are marked accessed.
fn CookieJar::header_at(
self : CookieJar,
url : RequestUrl,
now_ms : Int64,
) -> String? {
let secure = url.is_secure()
let selected : Array[Entry] = []
let mut expired = false
for domain in candidate_domains(url.host) {
guard self.domains.get(domain) is Some(entries) else { continue }
for entry in entries {
let cookie = entry.cookie
if cookie.is_expired(now_ms) {
expired = true
} else if (!cookie.host_only || domain == url.host) &&
path_match(url.path, cookie.path) &&
(!cookie.secure || secure) {
selected.push(entry)
}
}
}
if expired {
self.remove_where(cookie => cookie.is_expired(now_ms))
}
guard !selected.is_empty() else { return None }
selected.sort_by((a, b) => {
let by_path = b.cookie.path.length().compare(a.cookie.path.length())
if by_path != 0 {
return by_path
}
let by_creation = a.cookie.creation_ms.compare(b.cookie.creation_ms)
if by_creation != 0 {
by_creation
} else {
a.sequence.compare(b.sequence)
}
})
let header = StringBuilder()
for i, entry in selected {
entry.cookie = { ..entry.cookie, last_access_ms: now_ms, }
if i > 0 {
header.write_string("; ")
}
header.write_string(entry.cookie.name)
header.write_char('=')
header.write_string(entry.cookie.value)
}
Some(header.to_string())
}