Skip to main content

web_faith_cookies/
lib.rs

1//! A cookie jar for HTTP clients, specialised for the server-side context.
2//!
3//! Cookies, as specified in RFC 6265 and 6265bis, are for browsers. Not all of the standard
4//! requirements apply to a server context. This jar implements browser-like behaviour, except
5//! where it doesn't make sense. Notably:
6//!
7//! - `SameSite` is parsed but never acted on. It governs cross-site behaviour, which needs a
8//!   first-party context to be cross-site *from*.
9//! - The public suffix list is not consulted, so a `Domain` that is a public suffix is not
10//!   rejected on that ground. A server-side caller talks to origins it chose.
11//! - A secure transport is `https`, rather than the wider "potentially trustworthy" origin
12//!   browsers accept. A `__Host-` cookie a browser would keep on `http://localhost` is rejected
13//!   here.
14//!
15//! # Example
16//!
17//! ```
18//! use url::Url;
19//! use web_faith_cookies::{CookieLimits, FaithJar};
20//!
21//! let jar = FaithJar::new(CookieLimits::default());
22//! let url = Url::parse("https://example.com/")?;
23//!
24//! jar.add_cookie_str("session=abc; Path=/", &url);
25//!
26//! let header = jar.request_cookie_header(&url).expect("a cookie to send");
27//! assert_eq!(header.to_str()?, "session=abc");
28//! # Ok::<(), Box<dyn std::error::Error>>(())
29//! ```
30//!
31//! # Features
32//!
33//! The `reqwest` feature enables support to use this jar with `reqwest::ClientBuilder`.
34
35#![deny(missing_docs)]
36// Lets docs.rs label each item with the feature or platform it needs.
37#![cfg_attr(docsrs, feature(doc_cfg))]
38
39// spec:COOK
40
41use std::{collections::HashMap, fmt, sync::RwLock, time::Duration};
42
43use cookie::{Cookie as RawCookie, Expiration};
44use cookie_store::{Cookie as StoredCookie, CookieStore as Store, StoreAction};
45use http::HeaderValue;
46use time::OffsetDateTime;
47use url::Url;
48
49/// A cookie may not persist beyond this by default. RFC 6265bis §5.5.
50pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(400 * 24 * 60 * 60);
51/// Default limit on one cookie's name plus value, in bytes.
52///
53/// RFC 6265bis §5.6 sets this as a floor servers may rely on; browsers implement it as the
54/// maximum, and so does this.
55pub const DEFAULT_MAX_SIZE: usize = 4096;
56/// Default limit on cookies kept for one domain.
57pub const DEFAULT_MAX_PER_HOST: usize = 180;
58/// Default limit on cookies kept across the whole jar.
59pub const DEFAULT_MAX_TOTAL: usize = 3000;
60
61/// The limits a jar enforces.
62#[derive(Debug, Clone)]
63pub struct CookieLimits {
64	/// Maximum expiry of a cookie. Larger values are clamped on insert.
65	pub max_age: Duration,
66	/// Maximum length of a cookie's name plus value, in bytes. A larger cookie is refused.
67	pub max_size: usize,
68	/// Maximum cookies kept for any one domain.
69	///
70	/// On exceeding it the jar drops that domain's expired cookies, then evicts its oldest until
71	/// the count fits, so the incoming cookie is always the one kept.
72	pub max_per_host: usize,
73	/// Maximum cookies kept across the jar.
74	///
75	/// Enforced after `max_per_host` and the same way, over the whole jar rather than one domain.
76	pub max_total: usize,
77}
78
79impl Default for CookieLimits {
80	fn default() -> Self {
81		Self {
82			max_age: DEFAULT_MAX_AGE,
83			max_size: DEFAULT_MAX_SIZE,
84			max_per_host: DEFAULT_MAX_PER_HOST,
85			max_total: DEFAULT_MAX_TOTAL,
86		}
87	}
88}
89
90/// The triple `cookie_store` keys a cookie by, so an evicted key can be passed straight to
91/// [`Store::remove`]: domain, path, name.
92type CookieKey = (String, String, String);
93
94fn key_of(cookie: &StoredCookie<'static>) -> CookieKey {
95	(
96		String::from(&cookie.domain),
97		String::from(&cookie.path),
98		cookie.name().to_owned(),
99	)
100}
101
102/// Whether cookies received from this URL count as coming over a secure transport.
103///
104/// The standard calls for `https`. Browsers widen this to any "potentially trustworthy" origin,
105/// which takes in `http://localhost`, so a `__Host-` cookie a browser would keep on a local dev
106/// server is rejected here.
107fn is_secure(url: &Url) -> bool {
108	url.scheme() == "https"
109}
110
111/// A cookie jar.
112#[derive(Debug)]
113pub struct FaithJar {
114	limits: CookieLimits,
115	inner: RwLock<Inner>,
116}
117
118#[derive(Debug, Default)]
119struct Inner {
120	store: Store,
121	/// When each stored cookie arrived, so the limits can evict the oldest. `cookie_store::Cookie`
122	/// carries neither a creation nor a last-access time, so the order is tracked alongside it.
123	order: HashMap<CookieKey, u64>,
124	next_seq: u64,
125}
126
127impl FaithJar {
128	/// Create a new empty jar.
129	pub fn new(limits: CookieLimits) -> Self {
130		Self {
131			limits,
132			inner: RwLock::new(Inner::default()),
133		}
134	}
135
136	/// Store one cookie against `url`, given as a `Set-Cookie` value.
137	pub fn add_cookie_str(&self, cookie: &str, url: &Url) -> Result<(), CookieRejected> {
138		let raw = RawCookie::parse(cookie.to_owned()).map_err(|_| CookieRejected::Malformed)?;
139		self.store_one(raw, url)
140	}
141
142	/// Gate a cookie on the bis rules, then hand it to the classic storage model.
143	fn store_one(&self, raw: RawCookie<'static>, url: &Url) -> Result<(), CookieRejected> {
144		let raw = self.sanitise(raw, url)?;
145		let mut inner = self.inner.write().unwrap();
146		inner.insert(&raw, url, &self.limits);
147		Ok(())
148	}
149
150	/// Apply the rules that decide whether a cookie is storable at all, and reduce an over-long
151	/// expiry to the limit.
152	fn sanitise(
153		&self,
154		mut raw: RawCookie<'static>,
155		url: &Url,
156	) -> Result<RawCookie<'static>, CookieRejected> {
157		if raw.name().len() + raw.value().len() > self.limits.max_size {
158			return Err(CookieRejected::TooLarge);
159		}
160
161		if !prefix_allows(&raw, url) {
162			return Err(CookieRejected::PrefixUnmet);
163		}
164
165		clamp_expiry(&mut raw, self.limits.max_age);
166		Ok(raw)
167	}
168}
169
170/// Why a cookie was not stored.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172#[non_exhaustive]
173pub enum CookieRejected {
174	/// The value did not parse as a `Set-Cookie`.
175	Malformed,
176	/// Name plus value exceeded [`CookieLimits::max_size`].
177	TooLarge,
178	/// A `__Host-` or `__Secure-` name prefix's requirements were not met.
179	PrefixUnmet,
180}
181
182impl fmt::Display for CookieRejected {
183	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184		f.write_str(match self {
185			Self::Malformed => "the cookie did not parse",
186			Self::TooLarge => "the cookie is over the size limit",
187			Self::PrefixUnmet => "the cookie's name prefix requires more than it carries",
188		})
189	}
190}
191
192impl std::error::Error for CookieRejected {}
193
194/// Whether a `__Host-` or `__Secure-` name prefix permits this cookie to be stored.
195///
196/// Matched case-sensitively, as RFC 6265bis §4.1.3 defines them, so a name differing only in case
197/// carries no requirement.
198fn prefix_allows(raw: &RawCookie<'_>, url: &Url) -> bool {
199	let secure = raw.secure().unwrap_or(false) && is_secure(url);
200
201	if raw.name().starts_with("__Host-") {
202		// Bound to the exact host that set it, at the root path.
203		return secure && raw.domain().is_none() && raw.path() == Some("/");
204	}
205
206	if raw.name().starts_with("__Secure-") {
207		return secure;
208	}
209
210	true
211}
212
213/// Reduce an expiry further ahead than `max_age` to `max_age` from now.
214///
215/// `Max-Age` is checked first, the precedence the storage model reads them in. A session cookie
216/// has neither and stays one.
217fn clamp_expiry(raw: &mut RawCookie<'static>, max_age: Duration) {
218	let limit = time::Duration::try_from(max_age).unwrap_or(time::Duration::MAX);
219
220	if let Some(max_age) = raw.max_age() {
221		if max_age > limit {
222			raw.set_max_age(limit);
223		}
224
225		return;
226	}
227
228	if let Some(Expiration::DateTime(expires)) = raw.expires() {
229		let latest = OffsetDateTime::now_utc().saturating_add(limit);
230		if expires > latest {
231			raw.set_expires(latest);
232		}
233	}
234}
235
236impl Inner {
237	fn insert(&mut self, raw: &RawCookie<'static>, url: &Url, limits: &CookieLimits) {
238		// The key is derived the same way the store derives it, so an eviction can name the cookie
239		// back to the store. A cookie the classic model rejects fails here too, and is dropped.
240		let Ok(parsed) = StoredCookie::try_from_raw_cookie(raw, url) else {
241			return;
242		};
243		let key = key_of(&parsed);
244
245		match self.store.insert_raw(raw, url) {
246			// A cookie that replaces one already stored keeps the original's place in the order,
247			// so a session refreshed on every request does not outlive older cookies by being
248			// rewritten.
249			Ok(StoreAction::Inserted | StoreAction::UpdatedExisting) => {
250				if !self.order.contains_key(&key) {
251					self.order.insert(key.clone(), self.next_seq);
252					self.next_seq += 1;
253				}
254			}
255			// The cookie was not stored: either it expired an existing cookie in place, which the
256			// expiry purge collects, or the storage model rejected it.
257			Ok(StoreAction::ExpiredExisting) | Err(_) => return,
258		}
259
260		self.enforce(&key.0, limits);
261	}
262
263	/// Bring the jar back within its limits, per domain and then overall.
264	///
265	/// Trimmed after the insert, so oldest-first eviction never picks the incoming cookie while an
266	/// older one remains.
267	fn enforce(&mut self, domain: &str, limits: &CookieLimits) {
268		if self.count(Some(domain)) > limits.max_per_host {
269			self.purge_expired();
270			self.evict_oldest(Some(domain), limits.max_per_host);
271		}
272
273		if self.count(None) > limits.max_total {
274			self.purge_expired();
275			self.evict_oldest(None, limits.max_total);
276		}
277	}
278
279	fn count(&self, domain: Option<&str>) -> usize {
280		match domain {
281			None => self.order.len(),
282			Some(domain) => self.order.keys().filter(|(d, ..)| d == domain).count(),
283		}
284	}
285
286	/// Drop cookies that have expired, so a limit evicts live cookies only once dead ones are gone.
287	fn purge_expired(&mut self) {
288		let expired: Vec<CookieKey> = self
289			.store
290			.iter_any()
291			.filter(|cookie| cookie.is_expired())
292			.map(key_of)
293			.collect();
294
295		for key in expired {
296			self.remove(&key);
297		}
298	}
299
300	/// Evict oldest-first until at most `limit` cookies remain in scope.
301	fn evict_oldest(&mut self, domain: Option<&str>, limit: usize) {
302		let mut scoped: Vec<(u64, CookieKey)> = self
303			.order
304			.iter()
305			.filter(|((d, ..), _)| domain.is_none_or(|domain| d == domain))
306			.map(|(key, seq)| (*seq, key.clone()))
307			.collect();
308
309		let excess = scoped.len().saturating_sub(limit);
310		if excess == 0 {
311			return;
312		}
313
314		scoped.sort_unstable();
315		for (_, key) in scoped.into_iter().take(excess) {
316			self.remove(&key);
317		}
318	}
319
320	/// Remove a cookie from the store and from the order alongside it, so the two never drift.
321	fn remove(&mut self, key: &CookieKey) {
322		let (domain, path, name) = key;
323		self.store.remove(domain, path, name);
324		self.order.remove(key);
325	}
326}
327
328impl FaithJar {
329	/// Store the cookies a response set, ignoring any the jar's rules refuse.
330	///
331	/// A header that is not valid UTF-8, or that does not parse as a cookie, is skipped rather than
332	/// failing the read: one bad `Set-Cookie` does not spoil the response.
333	pub fn store_response_cookies<'h>(
334		&self,
335		cookie_headers: impl Iterator<Item = &'h HeaderValue>,
336		url: &Url,
337	) {
338		for header in cookie_headers {
339			let Ok(header) = std::str::from_utf8(header.as_bytes()) else {
340				continue;
341			};
342
343			let Ok(raw) = RawCookie::parse(header.to_owned()) else {
344				continue;
345			};
346
347			let _ = self.store_one(raw, url);
348		}
349	}
350
351	/// The `Cookie` header to send to `url`, or `None` when the jar has nothing for it.
352	pub fn request_cookie_header(&self, url: &Url) -> Option<HeaderValue> {
353		let inner = self.inner.read().unwrap();
354		let cookies = inner
355			.store
356			.get_request_values(url)
357			.map(|(name, value)| format!("{name}={value}"))
358			.collect::<Vec<_>>()
359			.join("; ");
360
361		if cookies.is_empty() {
362			return None;
363		}
364
365		HeaderValue::from_str(&cookies).ok()
366	}
367}
368
369#[cfg(feature = "reqwest")]
370impl reqwest::cookie::CookieStore for FaithJar {
371	fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &Url) {
372		self.store_response_cookies(cookie_headers, url);
373	}
374
375	fn cookies(&self, url: &Url) -> Option<HeaderValue> {
376		self.request_cookie_header(url)
377	}
378}
379
380#[cfg(test)]
381mod tests {
382	use super::*;
383
384	fn url(url: &str) -> Url {
385		Url::parse(url).unwrap()
386	}
387
388	fn jar() -> FaithJar {
389		FaithJar::new(CookieLimits::default())
390	}
391
392	fn jar_with(limits: CookieLimits) -> FaithJar {
393		FaithJar::new(limits)
394	}
395
396	/// The `Cookie` header value the jar would send to `url`.
397	fn sent(jar: &FaithJar, url: &str) -> Option<String> {
398		jar.request_cookie_header(&self::url(url))
399			.map(|value| value.to_str().unwrap().to_owned())
400	}
401
402	fn stored_count(jar: &FaithJar) -> usize {
403		jar.inner.read().unwrap().store.iter_unexpired().count()
404	}
405
406	// Name prefixes
407
408	#[test]
409	fn host_prefix_accepted_when_fully_qualified() {
410		let jar = jar();
411		jar.add_cookie_str("__Host-a=1; Secure; Path=/", &url("https://example.com/"))
412			.expect("the cookie is stored");
413		assert_eq!(
414			sent(&jar, "https://example.com/"),
415			Some("__Host-a=1".into())
416		);
417	}
418
419	#[test]
420	fn host_prefix_rejected_without_secure_attribute() {
421		let jar = jar();
422		assert_eq!(
423			jar.add_cookie_str("__Host-a=1; Path=/", &url("https://example.com/")),
424			Err(CookieRejected::PrefixUnmet)
425		);
426		assert_eq!(sent(&jar, "https://example.com/"), None);
427	}
428
429	#[test]
430	fn host_prefix_rejected_over_insecure_transport() {
431		let jar = jar();
432		assert_eq!(
433			jar.add_cookie_str("__Host-a=1; Secure; Path=/", &url("http://example.com/")),
434			Err(CookieRejected::PrefixUnmet)
435		);
436		assert_eq!(sent(&jar, "http://example.com/"), None);
437	}
438
439	#[test]
440	fn host_prefix_rejected_with_domain_attribute() {
441		let jar = jar();
442		assert_eq!(
443			jar.add_cookie_str(
444				"__Host-a=1; Secure; Path=/; Domain=example.com",
445				&url("https://example.com/"),
446			),
447			Err(CookieRejected::PrefixUnmet)
448		);
449		assert_eq!(sent(&jar, "https://example.com/"), None);
450	}
451
452	#[test]
453	fn host_prefix_rejected_with_non_root_path() {
454		let jar = jar();
455		assert_eq!(
456			jar.add_cookie_str(
457				"__Host-a=1; Secure; Path=/app",
458				&url("https://example.com/app"),
459			),
460			Err(CookieRejected::PrefixUnmet)
461		);
462		assert_eq!(sent(&jar, "https://example.com/app"), None);
463	}
464
465	#[test]
466	fn host_prefix_rejected_without_path_attribute() {
467		let jar = jar();
468		assert_eq!(
469			jar.add_cookie_str("__Host-a=1; Secure", &url("https://example.com/")),
470			Err(CookieRejected::PrefixUnmet)
471		);
472		assert_eq!(sent(&jar, "https://example.com/"), None);
473	}
474
475	#[test]
476	fn secure_prefix_accepted_with_secure_attribute() {
477		let jar = jar();
478		jar.add_cookie_str("__Secure-a=1; Secure", &url("https://example.com/"))
479			.expect("the cookie is stored");
480		assert_eq!(
481			sent(&jar, "https://example.com/"),
482			Some("__Secure-a=1".into())
483		);
484	}
485
486	#[test]
487	fn secure_prefix_allows_domain_and_path_unlike_host() {
488		let jar = jar();
489		jar.add_cookie_str(
490			"__Secure-a=1; Secure; Path=/app; Domain=example.com",
491			&url("https://example.com/app"),
492		)
493		.expect("the cookie is stored");
494		assert_eq!(
495			sent(&jar, "https://sub.example.com/app"),
496			Some("__Secure-a=1".into())
497		);
498	}
499
500	#[test]
501	fn secure_prefix_rejected_without_secure_attribute() {
502		let jar = jar();
503		assert_eq!(
504			jar.add_cookie_str("__Secure-a=1", &url("https://example.com/")),
505			Err(CookieRejected::PrefixUnmet)
506		);
507		assert_eq!(sent(&jar, "https://example.com/"), None);
508	}
509
510	#[test]
511	fn secure_prefix_rejected_over_insecure_transport() {
512		let jar = jar();
513		assert_eq!(
514			jar.add_cookie_str("__Secure-a=1; Secure", &url("http://example.com/")),
515			Err(CookieRejected::PrefixUnmet)
516		);
517		assert_eq!(sent(&jar, "http://example.com/"), None);
518	}
519
520	#[test]
521	fn prefixes_are_matched_case_sensitively() {
522		let jar = jar();
523		// Differing in case, these carry no prefix requirement at all, so they store as ordinary
524		// cookies over an insecure transport.
525		jar.add_cookie_str("__host-a=1", &url("http://example.com/"))
526			.expect("the cookie is stored");
527		jar.add_cookie_str("__SECURE-b=2", &url("http://example.com/"))
528			.expect("the cookie is stored");
529
530		let sent = sent(&jar, "http://example.com/").unwrap();
531		assert!(sent.contains("__host-a=1"), "{sent}");
532		assert!(sent.contains("__SECURE-b=2"), "{sent}");
533	}
534
535	#[test]
536	fn unprefixed_cookies_are_unaffected() {
537		let jar = jar();
538		jar.add_cookie_str("a=1", &url("http://example.com/"))
539			.expect("the cookie is stored");
540		assert_eq!(sent(&jar, "http://example.com/"), Some("a=1".into()));
541	}
542
543	// Expiry limit
544
545	#[test]
546	fn over_long_max_age_is_reduced_to_the_limit() {
547		let jar = jar();
548		let over = DEFAULT_MAX_AGE.as_secs() * 2;
549		jar.add_cookie_str(
550			&format!("a=1; Max-Age={over}"),
551			&url("https://example.com/"),
552		)
553		.expect("the cookie is stored");
554
555		let inner = jar.inner.read().unwrap();
556		let cookie = inner.store.get("example.com", "/", "a").unwrap();
557		let limit = OffsetDateTime::now_utc() + time::Duration::try_from(DEFAULT_MAX_AGE).unwrap();
558		assert!(
559			cookie.expires_by(&(limit + time::Duration::minutes(1))),
560			"expiry should have been reduced to the limit"
561		);
562	}
563
564	#[test]
565	fn over_long_expires_is_reduced_to_the_limit() {
566		let jar = jar();
567		jar.add_cookie_str(
568			"a=1; Expires=Fri, 31 Dec 9999 23:59:59 GMT",
569			&url("https://example.com/"),
570		)
571		.expect("the cookie is stored");
572
573		let inner = jar.inner.read().unwrap();
574		let cookie = inner.store.get("example.com", "/", "a").unwrap();
575		let limit = OffsetDateTime::now_utc() + time::Duration::try_from(DEFAULT_MAX_AGE).unwrap();
576		assert!(
577			cookie.expires_by(&(limit + time::Duration::minutes(1))),
578			"expiry should have been reduced to the limit"
579		);
580	}
581
582	#[test]
583	fn shorter_expiry_is_left_alone() {
584		let jar = jar();
585		jar.add_cookie_str("a=1; Max-Age=60", &url("https://example.com/"))
586			.expect("the cookie is stored");
587
588		let inner = jar.inner.read().unwrap();
589		let cookie = inner.store.get("example.com", "/", "a").unwrap();
590		assert!(cookie.expires_by(&(OffsetDateTime::now_utc() + time::Duration::minutes(2))));
591		assert!(!cookie.expires_by(&(OffsetDateTime::now_utc() + time::Duration::seconds(30))));
592	}
593
594	#[test]
595	fn session_cookie_stays_a_session_cookie() {
596		let jar = jar();
597		jar.add_cookie_str("a=1", &url("https://example.com/"))
598			.expect("the cookie is stored");
599
600		let inner = jar.inner.read().unwrap();
601		let cookie = inner.store.get("example.com", "/", "a").unwrap();
602		assert!(!cookie.is_persistent(), "should not have gained an expiry");
603	}
604
605	#[test]
606	fn max_age_takes_precedence_over_expires() {
607		let jar = jar();
608		// Max-Age is within the limit, so the far-future Expires is never consulted and the cookie
609		// keeps its one-minute life.
610		jar.add_cookie_str(
611			"a=1; Max-Age=60; Expires=Fri, 31 Dec 9999 23:59:59 GMT",
612			&url("https://example.com/"),
613		)
614		.expect("the cookie is stored");
615
616		let inner = jar.inner.read().unwrap();
617		let cookie = inner.store.get("example.com", "/", "a").unwrap();
618		assert!(cookie.expires_by(&(OffsetDateTime::now_utc() + time::Duration::minutes(2))));
619	}
620
621	#[test]
622	fn expiring_a_cookie_still_works() {
623		// A server removes a cookie by resending it already expired; the limit must not get in the way.
624		let jar = jar();
625		jar.add_cookie_str("a=1", &url("https://example.com/"))
626			.expect("the cookie is stored");
627		assert_eq!(sent(&jar, "https://example.com/"), Some("a=1".into()));
628
629		jar.add_cookie_str("a=1; Max-Age=0", &url("https://example.com/"))
630			.expect("the cookie is stored");
631		assert_eq!(sent(&jar, "https://example.com/"), None);
632	}
633
634	#[test]
635	fn max_age_limit_is_configurable() {
636		let jar = jar_with(CookieLimits {
637			max_age: Duration::from_secs(60),
638			..Default::default()
639		});
640		jar.add_cookie_str("a=1; Max-Age=86400", &url("https://example.com/"))
641			.expect("the cookie is stored");
642
643		let inner = jar.inner.read().unwrap();
644		let cookie = inner.store.get("example.com", "/", "a").unwrap();
645		assert!(cookie.expires_by(&(OffsetDateTime::now_utc() + time::Duration::minutes(2))));
646	}
647
648	// Size limit
649
650	#[test]
651	fn oversized_cookie_is_rejected() {
652		let jar = jar();
653		let value = "x".repeat(DEFAULT_MAX_SIZE);
654		let _ = jar.add_cookie_str(&format!("a={value}"), &url("https://example.com/"));
655		assert_eq!(sent(&jar, "https://example.com/"), None);
656	}
657
658	#[test]
659	fn cookie_at_exactly_the_size_cap_is_stored() {
660		let jar = jar();
661		let value = "x".repeat(DEFAULT_MAX_SIZE - 1);
662		let _ = jar.add_cookie_str(&format!("a={value}"), &url("https://example.com/"));
663		assert_eq!(stored_count(&jar), 1);
664	}
665
666	#[test]
667	fn size_cap_counts_name_and_value_together() {
668		let jar = jar_with(CookieLimits {
669			max_size: 10,
670			..Default::default()
671		});
672		// The value alone is under the limit; with the name it is over.
673		assert_eq!(
674			jar.add_cookie_str("name=1234567", &url("https://example.com/")),
675			Err(CookieRejected::TooLarge)
676		);
677		assert_eq!(sent(&jar, "https://example.com/"), None);
678
679		jar.add_cookie_str("name=123456", &url("https://example.com/"))
680			.expect("the cookie is stored");
681		assert_eq!(
682			sent(&jar, "https://example.com/"),
683			Some("name=123456".into())
684		);
685	}
686
687	#[test]
688	fn size_cap_does_not_count_attributes() {
689		let jar = jar_with(CookieLimits {
690			max_size: 10,
691			..Default::default()
692		});
693		jar.add_cookie_str(
694			"name=12345; Path=/; Secure; HttpOnly",
695			&url("https://example.com/"),
696		)
697		.expect("the cookie is stored");
698		assert_eq!(stored_count(&jar), 1);
699	}
700
701	// Count limits
702
703	#[test]
704	fn per_host_cap_evicts_the_oldest() {
705		let jar = jar_with(CookieLimits {
706			max_per_host: 3,
707			..Default::default()
708		});
709		for n in 0..5 {
710			jar.add_cookie_str(&format!("c{n}=1"), &url("https://example.com/"))
711				.expect("the cookie is stored");
712		}
713
714		let sent = sent(&jar, "https://example.com/").unwrap();
715		assert_eq!(stored_count(&jar), 3, "{sent}");
716		assert!(!sent.contains("c0="), "oldest should have gone: {sent}");
717		assert!(
718			!sent.contains("c1="),
719			"next-oldest should have gone: {sent}"
720		);
721		assert!(sent.contains("c4=1"), "newest should have stayed: {sent}");
722	}
723
724	#[test]
725	fn per_host_cap_is_per_domain() {
726		let jar = jar_with(CookieLimits {
727			max_per_host: 2,
728			..Default::default()
729		});
730		for n in 0..3 {
731			jar.add_cookie_str(&format!("c{n}=1"), &url("https://one.example/"))
732				.expect("the cookie is stored");
733			jar.add_cookie_str(&format!("c{n}=1"), &url("https://two.example/"))
734				.expect("the cookie is stored");
735		}
736
737		assert_eq!(stored_count(&jar), 4, "each domain keeps its own allowance");
738	}
739
740	#[test]
741	fn refreshing_a_cookie_keeps_its_place_in_the_order() {
742		let jar = jar_with(CookieLimits {
743			max_per_host: 2,
744			..Default::default()
745		});
746		jar.add_cookie_str("a=1", &url("https://example.com/"))
747			.expect("the cookie is stored");
748		jar.add_cookie_str("b=1", &url("https://example.com/"))
749			.expect("the cookie is stored");
750		// Rewriting `a` must not make it younger than `b`, else a session cookie refreshed on every
751		// response would evict everything else in turn.
752		jar.add_cookie_str("a=2", &url("https://example.com/"))
753			.expect("the cookie is stored");
754		jar.add_cookie_str("c=1", &url("https://example.com/"))
755			.expect("the cookie is stored");
756
757		let sent = sent(&jar, "https://example.com/").unwrap();
758		assert!(
759			!sent.contains("a="),
760			"a was oldest and should have gone: {sent}"
761		);
762		assert!(sent.contains("b=1"), "{sent}");
763		assert!(sent.contains("c=1"), "{sent}");
764	}
765
766	#[test]
767	fn expired_cookies_are_purged_before_live_ones_are_evicted() {
768		let jar = jar_with(CookieLimits {
769			max_per_host: 3,
770			..Default::default()
771		});
772		// Two that die a second from now, then two that outlive them.
773		jar.add_cookie_str("dead1=1; Max-Age=1", &url("https://example.com/"))
774			.expect("the cookie is stored");
775		jar.add_cookie_str("dead2=1; Max-Age=1", &url("https://example.com/"))
776			.expect("the cookie is stored");
777		jar.add_cookie_str("live1=1", &url("https://example.com/"))
778			.expect("the cookie is stored");
779
780		std::thread::sleep(Duration::from_millis(1100));
781
782		// Storing this exceeds the limit; the two dead cookies go and `live1` survives.
783		jar.add_cookie_str("live2=1", &url("https://example.com/"))
784			.expect("the cookie is stored");
785
786		let sent = sent(&jar, "https://example.com/").unwrap();
787		assert!(sent.contains("live1=1"), "{sent}");
788		assert!(sent.contains("live2=1"), "{sent}");
789		assert_eq!(stored_count(&jar), 2, "{sent}");
790	}
791
792	#[test]
793	fn whole_jar_cap_bounds_cookies_spread_across_domains() {
794		let jar = jar_with(CookieLimits {
795			max_per_host: 100,
796			max_total: 5,
797			..Default::default()
798		});
799		for n in 0..10 {
800			jar.add_cookie_str("a=1", &url(&format!("https://host{n}.example/")))
801				.expect("the cookie is stored");
802		}
803
804		assert_eq!(stored_count(&jar), 5);
805		assert_eq!(
806			sent(&jar, "https://host0.example/"),
807			None,
808			"oldest domain evicted"
809		);
810		assert_eq!(sent(&jar, "https://host9.example/"), Some("a=1".into()));
811	}
812
813	#[test]
814	fn domain_scoped_cookies_are_evictable() {
815		// A cookie carrying `Domain` is keyed differently from a host-only one, and eviction names
816		// a cookie back to the store by that key: if the two disagreed, `remove` would quietly miss
817		// and the jar would drift past its limit.
818		let jar = jar_with(CookieLimits {
819			max_per_host: 2,
820			..Default::default()
821		});
822		for n in 0..4 {
823			jar.add_cookie_str(
824				&format!("c{n}=1; Domain=example.com"),
825				&url("https://example.com/"),
826			)
827			.expect("the cookie is stored");
828		}
829
830		let sent = sent(&jar, "https://www.example.com/").unwrap();
831		assert_eq!(stored_count(&jar), 2, "{sent}");
832		assert!(!sent.contains("c0="), "oldest should have gone: {sent}");
833		assert!(sent.contains("c3=1"), "newest should have stayed: {sent}");
834	}
835
836	#[test]
837	fn a_cap_of_zero_stores_nothing() {
838		let jar = jar_with(CookieLimits {
839			max_per_host: 0,
840			..Default::default()
841		});
842		jar.add_cookie_str("a=1", &url("https://example.com/"))
843			.expect("the cookie is stored");
844		assert_eq!(sent(&jar, "https://example.com/"), None);
845	}
846
847	// Storage model
848
849	#[test]
850	fn cookies_are_scoped_to_their_domain() {
851		let jar = jar();
852		jar.add_cookie_str("a=1", &url("https://example.com/"))
853			.expect("the cookie is stored");
854		assert_eq!(sent(&jar, "https://elsewhere.example/"), None);
855	}
856
857	#[test]
858	fn set_cookies_applies_the_same_rules_as_add_cookie() {
859		let jar = jar();
860		let headers = [
861			HeaderValue::from_static("__Host-good=1; Secure; Path=/"),
862			HeaderValue::from_static("__Host-bad=1; Path=/"),
863		];
864
865		jar.store_response_cookies(headers.iter(), &url("https://example.com/"));
866
867		let sent = sent(&jar, "https://example.com/").unwrap();
868		assert!(sent.contains("__Host-good=1"), "{sent}");
869		assert!(!sent.contains("__Host-bad"), "{sent}");
870	}
871
872	#[test]
873	fn an_unparseable_cookie_is_refused() {
874		let jar = jar();
875		assert_eq!(
876			jar.add_cookie_str("", &url("https://example.com/")),
877			Err(CookieRejected::Malformed)
878		);
879		assert_eq!(sent(&jar, "https://example.com/"), None);
880	}
881}