wreq-rb 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +257 -102
  3. data/ext/wreq_rb/Cargo.toml +4 -4
  4. data/ext/wreq_rb/src/client.rs +1 -1
  5. data/lib/wreq-rb/version.rb +1 -1
  6. data/patches/0001-add-transfer-size-tracking.patch +11 -15
  7. data/vendor/wreq/Cargo.toml +9 -8
  8. data/vendor/wreq/README.md +5 -5
  9. data/vendor/wreq/bench/support/bench.rs +6 -2
  10. data/vendor/wreq/bench/support/client.rs +88 -1
  11. data/vendor/wreq/bench/support/exec.rs +0 -0
  12. data/vendor/wreq/bench/support/rt.rs +34 -0
  13. data/vendor/wreq/bench/support/server.rs +1 -1
  14. data/vendor/wreq/bench/support.rs +1 -15
  15. data/vendor/wreq/examples/cert_store.rs +13 -13
  16. data/vendor/wreq/examples/request_with_emulate.rs +1 -1
  17. data/vendor/wreq/examples/tcp_linger.rs +22 -0
  18. data/vendor/wreq/src/client/layer/client/pool.rs +17 -17
  19. data/vendor/wreq/src/client/layer/client.rs +2 -0
  20. data/vendor/wreq/src/client/layer/decoder.rs +71 -17
  21. data/vendor/wreq/src/client/layer/redirect/future.rs +49 -63
  22. data/vendor/wreq/src/client/layer/redirect/policy.rs +2 -26
  23. data/vendor/wreq/src/client/layer/redirect.rs +48 -60
  24. data/vendor/wreq/src/client/layer/retry.rs +12 -15
  25. data/vendor/wreq/src/client/layer/timeout/body.rs +27 -21
  26. data/vendor/wreq/src/client/layer/timeout/future.rs +33 -58
  27. data/vendor/wreq/src/client/layer/timeout.rs +8 -14
  28. data/vendor/wreq/src/client/request.rs +4 -0
  29. data/vendor/wreq/src/client.rs +53 -31
  30. data/vendor/wreq/src/conn/connector.rs +99 -129
  31. data/vendor/wreq/src/conn/http.rs +25 -18
  32. data/vendor/wreq/src/conn/net/tcp.rs +601 -107
  33. data/vendor/wreq/src/conn/proxy/socks.rs +6 -6
  34. data/vendor/wreq/src/conn/timeout.rs +166 -0
  35. data/vendor/wreq/src/conn.rs +5 -4
  36. data/vendor/wreq/src/cookie/jar.rs +1225 -0
  37. data/vendor/wreq/src/cookie/store.rs +321 -0
  38. data/vendor/wreq/src/cookie.rs +108 -612
  39. data/vendor/wreq/src/dns/resolve.rs +8 -2
  40. data/vendor/wreq/src/dns.rs +4 -4
  41. data/vendor/wreq/src/error.rs +53 -20
  42. data/vendor/wreq/src/lib.rs +1 -0
  43. data/vendor/wreq/src/proxy/matcher.rs +26 -12
  44. data/vendor/wreq/src/proxy/win.rs +39 -9
  45. data/vendor/wreq/src/redirect.rs +515 -100
  46. data/vendor/wreq/src/tls/conn.rs +3 -11
  47. data/vendor/wreq/src/tls/session.rs +7 -8
  48. data/vendor/wreq/src/tls/trust/store.rs +4 -4
  49. data/vendor/wreq/src/util.rs +23 -0
  50. data/vendor/wreq/tests/badssl.rs +72 -7
  51. data/vendor/wreq/tests/brotli.rs +1 -1
  52. data/vendor/wreq/tests/client.rs +24 -0
  53. data/vendor/wreq/tests/connector_layers.rs +8 -4
  54. data/vendor/wreq/tests/cookie.rs +59 -0
  55. data/vendor/wreq/tests/deflate.rs +1 -1
  56. data/vendor/wreq/tests/gzip.rs +53 -1
  57. data/vendor/wreq/tests/layers.rs +8 -4
  58. data/vendor/wreq/tests/redirect.rs +180 -97
  59. data/vendor/wreq/tests/timeouts.rs +47 -12
  60. data/vendor/wreq/tests/zstd.rs +1 -1
  61. metadata +7 -1
@@ -0,0 +1,1225 @@
1
+ use bytes::Bytes;
2
+ use cookie::{
3
+ Cookie as RawCookie,
4
+ time::{Duration, OffsetDateTime},
5
+ };
6
+ use http::{Uri, Version};
7
+
8
+ use super::{
9
+ Cookie, CookieStore, Cookies, IntoCookie,
10
+ store::{DEFAULT_PATH, Store, canonical_host, cookie_is_expired, domain_match, normalize_path},
11
+ };
12
+ use crate::{IntoUri, ext::UriExt, header::HeaderValue, sync::RwLock};
13
+
14
+ /// A good default `CookieStore` implementation.
15
+ ///
16
+ /// This is the implementation used when simply calling `cookie_store(true)`.
17
+ /// This type is exposed to allow creating one and filling it with some
18
+ /// existing cookies more easily, before creating a [`crate::Client`].
19
+ #[derive(Debug, Default)]
20
+ pub struct Jar(RwLock<Store>);
21
+
22
+ macro_rules! into_uri {
23
+ ($expr:expr) => {
24
+ match $expr.into_uri() {
25
+ Ok(u) => u,
26
+ Err(_) => return,
27
+ }
28
+ };
29
+ }
30
+
31
+ impl Jar {
32
+ /// Returns an unexpired cookie by name for an exact URI scope.
33
+ ///
34
+ /// The URI host is canonicalized and its path is used as the exact stored cookie path. Use
35
+ /// [`matches`](Self::matches) to select every cookie that would apply to a request URI through
36
+ /// RFC domain and path matching. When both storage scopes contain the name, the older cookie is
37
+ /// returned.
38
+ ///
39
+ /// # Example
40
+ /// ```
41
+ /// use wreq::cookie::Jar;
42
+ /// let jar = Jar::default();
43
+ /// jar.add("foo=bar; Path=/foo; Domain=example.com", "http://example.com/foo");
44
+ /// let cookie = jar.get("foo", "http://example.com/foo").unwrap();
45
+ /// assert_eq!(cookie.value(), "bar");
46
+ /// ```
47
+ pub fn get<U: IntoUri>(&self, name: &str, uri: U) -> Option<Cookie<'static>> {
48
+ let uri = uri.into_uri().ok()?;
49
+ let host = canonical_host(uri.host()?)?;
50
+ let now = OffsetDateTime::now_utc();
51
+ let store = self.0.read();
52
+ let cookie = store
53
+ .cookies
54
+ .get(&host)?
55
+ .get(uri.path())?
56
+ .entries(name)
57
+ .filter(|entry| !cookie_is_expired(&entry.cookie, now))
58
+ .min_by_key(|entry| entry.creation_index)?;
59
+
60
+ Some(Cookie::from(cookie.cookie.clone()))
61
+ }
62
+
63
+ /// Returns whether an unexpired cookie exists for an exact URI scope.
64
+ ///
65
+ /// This performs the same scope lookup as [`get`](Self::get) without cloning the cookie.
66
+ pub fn contains<U: IntoUri>(&self, name: &str, uri: U) -> bool {
67
+ let Ok(uri) = uri.into_uri() else {
68
+ return false;
69
+ };
70
+ let Some(host) = uri.host().and_then(canonical_host) else {
71
+ return false;
72
+ };
73
+ let now = OffsetDateTime::now_utc();
74
+
75
+ self.0
76
+ .read()
77
+ .cookies
78
+ .get(&host)
79
+ .and_then(|path_map| path_map.get(uri.path()))
80
+ .is_some_and(|cookie_map| {
81
+ cookie_map
82
+ .entries(name)
83
+ .any(|entry| !cookie_is_expired(&entry.cookie, now))
84
+ })
85
+ }
86
+
87
+ /// Returns all unexpired cookies in the jar.
88
+ ///
89
+ /// The returned cookies are owned snapshots with their effective stored `Path`. Host-only
90
+ /// cookies keep their `Domain` attribute absent, so importing a snapshot into another jar does
91
+ /// not broaden its domain scope. Snapshots are returned in creation order.
92
+ ///
93
+ /// # Example
94
+ /// ```
95
+ /// use wreq::cookie::Jar;
96
+ /// let jar = Jar::default();
97
+ /// jar.add("foo=bar; Domain=example.com", "http://example.com");
98
+ /// for cookie in jar.get_all() {
99
+ /// println!("{}={}", cookie.name(), cookie.value());
100
+ /// }
101
+ /// ```
102
+ pub fn get_all(&self) -> impl Iterator<Item = Cookie<'static>> {
103
+ let now = OffsetDateTime::now_utc();
104
+ let mut cookies = self
105
+ .0
106
+ .read()
107
+ .cookies
108
+ .values()
109
+ .flat_map(|path_map| {
110
+ path_map.values().flat_map(|cookie_map| {
111
+ cookie_map.values().filter_map(|entry| {
112
+ if cookie_is_expired(&entry.cookie, now) {
113
+ return None;
114
+ }
115
+
116
+ Some((entry.creation_index, Cookie::from(entry.cookie.clone())))
117
+ })
118
+ })
119
+ })
120
+ .collect::<Vec<_>>();
121
+ cookies.sort_unstable_by_key(|(creation_index, _)| *creation_index);
122
+ cookies.into_iter().map(|(_, cookie)| cookie)
123
+ }
124
+
125
+ /// Returns the number of unexpired cookies in the jar.
126
+ ///
127
+ /// Expired cookies are excluded even if their internal entries have not yet been overwritten or
128
+ /// cleared.
129
+ pub fn len(&self) -> usize {
130
+ let now = OffsetDateTime::now_utc();
131
+ self.0
132
+ .read()
133
+ .cookies
134
+ .values()
135
+ .flat_map(|path_map| path_map.values())
136
+ .flat_map(|cookie_map| cookie_map.values())
137
+ .filter(|entry| !cookie_is_expired(&entry.cookie, now))
138
+ .count()
139
+ }
140
+
141
+ /// Returns `true` when the jar has no unexpired cookies.
142
+ pub fn is_empty(&self) -> bool {
143
+ let now = OffsetDateTime::now_utc();
144
+ !self
145
+ .0
146
+ .read()
147
+ .cookies
148
+ .values()
149
+ .flat_map(|path_map| path_map.values())
150
+ .flat_map(|cookie_map| cookie_map.values())
151
+ .any(|entry| !cookie_is_expired(&entry.cookie, now))
152
+ }
153
+
154
+ /// Returns the unexpired cookies that apply to an HTTP or HTTPS request URI.
155
+ ///
156
+ /// Selection applies domain matching, path matching, host-only scope, the `Secure` attribute,
157
+ /// and expiration rules from the [RFC 6265 retrieval model]. Returned cookies are owned
158
+ /// snapshots that preserve host-only scope. Unsupported URI schemes do not match any cookies.
159
+ ///
160
+ /// # Example
161
+ ///
162
+ /// ```
163
+ /// use wreq::cookie::Jar;
164
+ ///
165
+ /// let jar = Jar::default();
166
+ /// jar.add("root=1; Domain=example.com; Path=/", "https://example.com/");
167
+ /// jar.add("api=2; Domain=example.com; Path=/api", "https://example.com/api");
168
+ ///
169
+ /// let cookies = jar
170
+ /// .matches("https://www.example.com/api/users")
171
+ /// .collect::<Vec<_>>();
172
+ /// assert_eq!(cookies.len(), 2);
173
+ /// assert!(cookies.iter().any(|cookie| cookie.name() == "root"));
174
+ /// assert!(cookies.iter().any(|cookie| cookie.name() == "api"));
175
+ /// ```
176
+ ///
177
+ /// [RFC 6265 retrieval model]: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.4
178
+ pub fn matches<U: IntoUri>(&self, uri: U) -> impl Iterator<Item = Cookie<'static>> {
179
+ let Ok(uri) = uri.into_uri() else {
180
+ return Vec::new().into_iter();
181
+ };
182
+ let Some(host) = uri.host().and_then(canonical_host) else {
183
+ return Vec::new().into_iter();
184
+ };
185
+
186
+ let now = OffsetDateTime::now_utc();
187
+ let store = self.0.read();
188
+ store
189
+ .matching_cookies(&uri, &host, now)
190
+ .map(|(_, _, entry)| Cookie::from(entry.cookie.clone()))
191
+ .collect::<Vec<_>>()
192
+ .into_iter()
193
+ }
194
+
195
+ /// Stores a cookie received from a URI.
196
+ ///
197
+ /// The URI supplies the host-only domain and default path when those attributes are absent.
198
+ /// Cookies with an invalid URI or an invalid or mismatched `Domain` are ignored. A non-positive
199
+ /// `Max-Age` removes an existing cookie in the same scope, as required by the
200
+ /// [RFC 6265 storage model]. A positive `Max-Age` is stored as an absolute deadline. Insecure
201
+ /// origins cannot set `Secure` cookies or overlay an existing `Secure` cookie, following the
202
+ /// [RFC 6265bis storage model].
203
+ ///
204
+ /// # Example
205
+ ///
206
+ /// ```
207
+ /// use wreq::cookie::Jar;
208
+ /// use cookie::CookieBuilder;
209
+ /// let jar = Jar::default();
210
+ /// let cookie = CookieBuilder::new("foo", "bar")
211
+ /// .domain("example.com")
212
+ /// .path("/")
213
+ /// .build();
214
+ /// jar.add(cookie, "http://example.com");
215
+ /// ```
216
+ ///
217
+ /// [RFC 6265 storage model]: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.3
218
+ /// [RFC 6265bis storage model]: https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-5.7
219
+ pub fn add<C, U>(&self, cookie: C, uri: U)
220
+ where
221
+ C: IntoCookie,
222
+ U: IntoUri,
223
+ {
224
+ if let Some(cookie) = cookie.into_cookie() {
225
+ let uri = into_uri!(uri);
226
+ let mut cookie: RawCookie<'static> = cookie.into();
227
+ let secure_origin = uri.is_https();
228
+
229
+ if cookie.secure() == Some(true) && !secure_origin {
230
+ return;
231
+ }
232
+
233
+ // If the request-uri contains no host component:
234
+ let Some(host) = uri.host().and_then(canonical_host) else {
235
+ return;
236
+ };
237
+ let host_only = cookie.domain().is_none();
238
+
239
+ // If the canonicalized request-host does not domain-match the
240
+ // domain-attribute:
241
+ // Ignore the cookie entirely and abort these steps.
242
+ //
243
+ // RFC 6265 §5.3 + §5.1.3:
244
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
245
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
246
+ let domain = if let Some(raw_domain) = cookie.domain() {
247
+ let Some(domain) = canonical_host(raw_domain) else {
248
+ return;
249
+ };
250
+ if !domain_match(&host, &domain) {
251
+ return;
252
+ }
253
+
254
+ cookie.set_domain(domain.to_string());
255
+ domain
256
+ } else {
257
+ host
258
+ };
259
+
260
+ // Max-Age takes precedence over Expires and is relative to when the cookie is
261
+ // received. Store its effective deadline so every read path applies the same
262
+ // expiration decision. RFC 6265 sections 5.2.2 and 5.3:
263
+ // https://www.rfc-editor.org/rfc/rfc6265.html#section-5.2.2
264
+ // https://www.rfc-editor.org/rfc/rfc6265.html#section-5.3
265
+ let now = OffsetDateTime::now_utc();
266
+ let expired = match cookie.max_age() {
267
+ Some(max_age) if max_age <= Duration::ZERO => true,
268
+ Some(max_age) => {
269
+ let deadline = now.saturating_add(max_age);
270
+ cookie.set_max_age(None);
271
+ cookie.set_expires(deadline);
272
+ false
273
+ }
274
+ None => cookie
275
+ .expires_datetime()
276
+ .is_some_and(|deadline| deadline <= now),
277
+ };
278
+
279
+ // If the request-uri contains no path component or if the first character of the
280
+ // path component of the request-uri is not a %x2F ("/") OR if the cookie's path-
281
+ // attribute is missing or does not start with a %x2F ("/"):
282
+ // Let cookie-path be the default-path of the request-uri.
283
+ // Otherwise:
284
+ // Let cookie-path be the substring of the request-uri's path from the first
285
+ // character up to, not including, the right-most %x2F ("/").
286
+ //
287
+ // RFC 6265 §5.2.4 + §5.1.4:
288
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4
289
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4
290
+ let path = cookie
291
+ .path()
292
+ .filter(|path| path.starts_with(DEFAULT_PATH))
293
+ .unwrap_or_else(|| normalize_path(uri.path()))
294
+ .to_owned();
295
+
296
+ let mut store = self.0.write();
297
+ if !secure_origin
298
+ && store.would_overlay_secure_cookie(cookie.name(), &domain, &path, now)
299
+ {
300
+ return;
301
+ }
302
+
303
+ if expired {
304
+ store.remove_stored_cookie(&domain, &path, cookie.name(), host_only);
305
+ } else {
306
+ cookie.set_path(path.clone());
307
+ store.insert_stored_cookie(domain, path, cookie);
308
+ }
309
+ }
310
+ }
311
+
312
+ /// Removes a cookie from an exact URI scope.
313
+ ///
314
+ /// The URI host and path identify the stored scope. Both host-only and `Domain` cookies with
315
+ /// the same name in that scope are removed; other domains and paths are left unchanged.
316
+ ///
317
+ /// # Example
318
+ /// ```
319
+ /// use wreq::cookie::Jar;
320
+ /// let jar = Jar::default();
321
+ /// jar.add("foo=bar; Path=/foo; Domain=example.com", "http://example.com/foo");
322
+ /// assert!(jar.get("foo", "http://example.com/foo").is_some());
323
+ /// jar.remove("foo", "http://example.com/foo");
324
+ /// assert!(jar.get("foo", "http://example.com/foo").is_none());
325
+ /// ```
326
+ pub fn remove<C, U>(&self, cookie: C, uri: U)
327
+ where
328
+ C: Into<RawCookie<'static>>,
329
+ U: IntoUri,
330
+ {
331
+ let uri = into_uri!(uri);
332
+ if let Some(host) = uri.host() {
333
+ let Some(host) = canonical_host(host) else {
334
+ return;
335
+ };
336
+ let cookie = cookie.into();
337
+ let mut store = self.0.write();
338
+ store.remove_stored_cookies(&host, uri.path(), cookie.name());
339
+ }
340
+ }
341
+
342
+ /// Removes every cookie from the jar, leaving it empty.
343
+ ///
344
+ /// # Example
345
+ /// ```
346
+ /// use wreq::cookie::Jar;
347
+ /// let jar = Jar::default();
348
+ /// jar.add("foo=bar; Domain=example.com", "http://example.com");
349
+ /// assert_eq!(jar.get_all().count(), 1);
350
+ /// jar.clear();
351
+ /// assert_eq!(jar.get_all().count(), 0);
352
+ /// ```
353
+ pub fn clear(&self) {
354
+ *self.0.write() = Store::default();
355
+ }
356
+ }
357
+
358
+ impl CookieStore for Jar {
359
+ fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, uri: &Uri) {
360
+ let cookies = cookie_headers
361
+ .map(Cookie::parse)
362
+ .filter_map(Result::ok)
363
+ .map(|cookie| RawCookie::from(cookie).into_owned());
364
+
365
+ for cookie in cookies {
366
+ self.add(cookie, uri);
367
+ }
368
+ }
369
+
370
+ fn cookies(&self, uri: &Uri, version: Version) -> Cookies {
371
+ let host = match uri.host() {
372
+ Some(host) => match canonical_host(host) {
373
+ Some(host) => host,
374
+ None => return Cookies::Empty,
375
+ },
376
+ None => return Cookies::Empty,
377
+ };
378
+
379
+ let now = OffsetDateTime::now_utc();
380
+ let store = self.0.read();
381
+ let mut matches = store.matching_cookies(uri, &host, now).collect::<Vec<_>>();
382
+
383
+ // Chromium sorts cookies selected for a request by longest path first, then oldest
384
+ // creation time. Cookie names and domains do not participate in the comparison.
385
+ // RFC 6265 section 5.4: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.4
386
+ // Chromium: https://chromium.googlesource.com/chromium/src/+/main/net/cookies/cookie_monster.cc
387
+ matches.sort_unstable_by(|(_, left_path, left), (_, right_path, right)| {
388
+ right_path
389
+ .len()
390
+ .cmp(&left_path.len())
391
+ .then_with(|| left.creation_index.cmp(&right.creation_index))
392
+ });
393
+
394
+ let iter = matches.into_iter().map(|(_, _, entry)| &entry.cookie);
395
+
396
+ if matches!(version, Version::HTTP_2 | Version::HTTP_3) {
397
+ let cookies = iter
398
+ .map(|cookie| {
399
+ let name = cookie.name();
400
+ let value = cookie.value();
401
+
402
+ let mut cookie_str = String::with_capacity(name.len() + 1 + value.len());
403
+ cookie_str.push_str(name);
404
+ cookie_str.push('=');
405
+ cookie_str.push_str(value);
406
+
407
+ HeaderValue::from_maybe_shared(Bytes::from(cookie_str))
408
+ })
409
+ .filter_map(Result::ok)
410
+ .collect();
411
+
412
+ Cookies::Uncompressed(cookies)
413
+ } else {
414
+ let cookies = iter.fold(String::new(), |mut cookies, cookie| {
415
+ if !cookies.is_empty() {
416
+ cookies.push_str("; ");
417
+ }
418
+ cookies.push_str(cookie.name());
419
+ cookies.push('=');
420
+ cookies.push_str(cookie.value());
421
+ cookies
422
+ });
423
+
424
+ if cookies.is_empty() {
425
+ return Cookies::Empty;
426
+ }
427
+
428
+ HeaderValue::from_maybe_shared(Bytes::from(cookies))
429
+ .map(Cookies::Compressed)
430
+ .unwrap_or(Cookies::Empty)
431
+ }
432
+ }
433
+ }
434
+
435
+ #[cfg(test)]
436
+ mod tests {
437
+ use std::{thread, time::Duration as StdDuration};
438
+
439
+ use http::{Uri, Version};
440
+
441
+ use super::{CookieStore, Cookies, Jar};
442
+
443
+ #[test]
444
+ fn jar_get_all_preserves_host_only_scope_and_effective_path() {
445
+ let jar = Jar::default();
446
+ jar.add("session=abc", "http://example.com/foo/bar");
447
+
448
+ let cookies = jar.get_all().collect::<Vec<_>>();
449
+ assert_eq!(cookies.len(), 1);
450
+
451
+ let cookie = &cookies[0];
452
+ assert_eq!(cookie.name(), "session");
453
+ assert_eq!(cookie.value(), "abc");
454
+ assert_eq!(cookie.domain(), None);
455
+ assert_eq!(cookie.path(), Some("/foo"));
456
+ }
457
+
458
+ #[test]
459
+ fn jar_get_all_keeps_existing_domain_and_path() {
460
+ let jar = Jar::default();
461
+ jar.add(
462
+ "session=abc; Domain=example.com; Path=/custom",
463
+ "http://example.com/foo/bar",
464
+ );
465
+
466
+ let cookies = jar.get_all().collect::<Vec<_>>();
467
+ assert_eq!(cookies.len(), 1);
468
+
469
+ let cookie = &cookies[0];
470
+ assert_eq!(cookie.name(), "session");
471
+ assert_eq!(cookie.value(), "abc");
472
+ assert_eq!(cookie.domain(), Some("example.com"));
473
+ assert_eq!(cookie.path(), Some("/custom"));
474
+ }
475
+
476
+ #[test]
477
+ fn jar_get_all_preserves_explicit_domain_and_host_only_scope() {
478
+ let jar = Jar::default();
479
+ jar.add("a=1; Domain=example.com", "http://example.com/foo/bar");
480
+ jar.add("b=2; Path=/fixed", "http://example.com/foo/bar");
481
+
482
+ let mut cookies = jar.get_all().collect::<Vec<_>>();
483
+ cookies.sort_by(|left, right| left.name().cmp(right.name()));
484
+
485
+ let a = &cookies[0];
486
+ assert_eq!(a.name(), "a");
487
+ assert_eq!(a.domain(), Some("example.com"));
488
+ assert_eq!(a.path(), Some("/foo"));
489
+
490
+ let b = &cookies[1];
491
+ assert_eq!(b.name(), "b");
492
+ assert_eq!(b.domain(), None);
493
+ assert_eq!(b.path(), Some("/fixed"));
494
+ }
495
+
496
+ #[test]
497
+ fn jar_add_rejects_mismatched_domain() {
498
+ let jar = Jar::default();
499
+ jar.add("session=abc; Domain=other.com", "http://example.com/foo");
500
+
501
+ assert_eq!(jar.get_all().count(), 0);
502
+ }
503
+
504
+ #[test]
505
+ fn jar_add_accepts_matching_parent_domain() {
506
+ let jar = Jar::default();
507
+ jar.add(
508
+ "session=abc; Domain=example.com",
509
+ "http://api.example.com/foo",
510
+ );
511
+
512
+ let cookies = jar.get_all().collect::<Vec<_>>();
513
+ assert_eq!(cookies.len(), 1);
514
+ assert_eq!(cookies[0].domain(), Some("example.com"));
515
+ }
516
+
517
+ #[test]
518
+ fn jar_rejects_secure_cookie_from_insecure_origin() {
519
+ let jar = Jar::default();
520
+
521
+ jar.add("session=secure; Secure; Path=/", "http://example.com/");
522
+ assert!(jar.is_empty());
523
+
524
+ jar.add("session=plain; Path=/", "https://example.com/");
525
+ jar.add(
526
+ "session=gone; Secure; Max-Age=0; Path=/",
527
+ "http://example.com/",
528
+ );
529
+
530
+ let cookie = jar
531
+ .get("session", "https://example.com/")
532
+ .expect("insecure deletion must not remove the stored cookie");
533
+ assert_eq!(cookie.value(), "plain");
534
+ }
535
+
536
+ #[test]
537
+ fn jar_blocks_insecure_cookie_that_overlays_secure_path() {
538
+ let jar = Jar::default();
539
+ jar.add(
540
+ "session=secure; Secure; Domain=example.com; Path=/login",
541
+ "https://example.com/login",
542
+ );
543
+
544
+ for cookie in [
545
+ "session=exact; Domain=example.com; Path=/login",
546
+ "session=deeper; Domain=example.com; Path=/login/profile",
547
+ "session=gone; Domain=example.com; Path=/login; Max-Age=0",
548
+ ] {
549
+ jar.add(cookie, "http://example.com/login/profile");
550
+ }
551
+
552
+ // A shorter path does not overlay the existing Secure cookie.
553
+ jar.add(
554
+ "session=root; Domain=example.com; Path=/",
555
+ "http://example.com/",
556
+ );
557
+
558
+ let uri = Uri::from_static("https://example.com/login/profile");
559
+ match jar.cookies(&uri, Version::HTTP_11) {
560
+ Cookies::Compressed(value) => assert_eq!(value, "session=secure; session=root"),
561
+ other => panic!("expected protected Secure cookie, got {other:?}"),
562
+ }
563
+ }
564
+
565
+ #[test]
566
+ fn jar_blocks_secure_cookie_overlay_across_related_domains() {
567
+ let parent = Jar::default();
568
+ parent.add(
569
+ "session=secure; Secure; Domain=example.com; Path=/",
570
+ "https://example.com/",
571
+ );
572
+ parent.add(
573
+ "session=child; Domain=api.example.com; Path=/",
574
+ "http://api.example.com/",
575
+ );
576
+ assert_eq!(parent.get_all().count(), 1);
577
+
578
+ let child = Jar::default();
579
+ child.add(
580
+ "session=secure; Secure; Domain=api.example.com; Path=/",
581
+ "https://api.example.com/",
582
+ );
583
+ child.add(
584
+ "session=parent; Domain=example.com; Path=/",
585
+ "http://example.com/",
586
+ );
587
+ assert_eq!(child.get_all().count(), 1);
588
+ }
589
+
590
+ #[test]
591
+ fn jar_allows_secure_origin_to_replace_secure_cookie() {
592
+ let jar = Jar::default();
593
+ let uri = "https://example.com/";
594
+ jar.add("session=secure; Secure; Path=/", uri);
595
+ jar.add("session=plain; Path=/", uri);
596
+
597
+ let cookie = jar
598
+ .get("session", uri)
599
+ .expect("secure origin should replace the cookie");
600
+ assert_eq!(cookie.value(), "plain");
601
+ assert!(!cookie.secure());
602
+ }
603
+
604
+ #[test]
605
+ fn jar_get_all_export_import_keeps_host_only_scope_and_effective_path() {
606
+ let source = Jar::default();
607
+ source.add("session=abc", "http://example.com/foo/bar");
608
+
609
+ let exported = source.get_all().collect::<Vec<_>>();
610
+ assert_eq!(exported.len(), 1);
611
+ assert_eq!(exported[0].domain(), None);
612
+ assert_eq!(exported[0].path(), Some("/foo"));
613
+
614
+ let target = Jar::default();
615
+ for cookie in exported {
616
+ target.add(cookie, "http://example.com/another/deeper");
617
+ }
618
+
619
+ let imported = target.get_all().collect::<Vec<_>>();
620
+ assert_eq!(imported.len(), 1);
621
+ assert_eq!(imported[0].domain(), None);
622
+ assert_eq!(imported[0].path(), Some("/foo"));
623
+
624
+ let subdomain = Uri::from_static("http://api.example.com/foo/resource");
625
+ assert!(matches!(
626
+ target.cookies(&subdomain, Version::HTTP_11),
627
+ Cookies::Empty
628
+ ));
629
+ }
630
+
631
+ #[test]
632
+ fn jar_get_all_export_import_preserves_absolute_expiration() {
633
+ let source = Jar::default();
634
+ let uri = "http://example.com/";
635
+ source.add("session=abc; Max-Age=60; Path=/", uri);
636
+
637
+ let exported = source
638
+ .get_all()
639
+ .next()
640
+ .expect("source cookie should be stored");
641
+ let expires = exported.expires();
642
+ assert_eq!(exported.max_age(), None);
643
+ assert!(expires.is_some());
644
+
645
+ let target = Jar::default();
646
+ target.add(exported, uri);
647
+
648
+ let imported = target
649
+ .get("session", uri)
650
+ .expect("snapshot should be imported");
651
+ assert_eq!(imported.max_age(), None);
652
+ assert_eq!(imported.expires(), expires);
653
+ }
654
+
655
+ #[test]
656
+ fn jar_get_all_export_import_preserves_creation_order() {
657
+ let source = Jar::default();
658
+ let uri = "http://example.com/";
659
+ source.add("B=first; Path=/", uri);
660
+ source.add("A=second; Path=/", uri);
661
+
662
+ let exported = source.get_all().collect::<Vec<_>>();
663
+ assert_eq!(
664
+ exported
665
+ .iter()
666
+ .map(|cookie| cookie.name())
667
+ .collect::<Vec<_>>(),
668
+ ["B", "A"]
669
+ );
670
+
671
+ let target = Jar::default();
672
+ for cookie in exported {
673
+ target.add(cookie, uri);
674
+ }
675
+
676
+ let uri = Uri::from_static("http://example.com/");
677
+ match target.cookies(&uri, Version::HTTP_11) {
678
+ Cookies::Compressed(value) => assert_eq!(value, "B=first; A=second"),
679
+ other => panic!("expected imported Cookie field, got {other:?}"),
680
+ }
681
+ }
682
+
683
+ #[test]
684
+ fn cookie_store_invalid_explicit_path_falls_back_to_default_path() {
685
+ let jar = Jar::default();
686
+ jar.add("key=val; Path=noslash", "http://example.com/foo/bar");
687
+
688
+ assert!(jar.get("key", "http://example.com/foo").is_some());
689
+ assert!(jar.get("key", "http://example.com/noslash").is_none());
690
+
691
+ let cookies = jar.get_all().collect::<Vec<_>>();
692
+ assert_eq!(cookies.len(), 1);
693
+ assert_eq!(cookies[0].path(), Some("/foo"));
694
+ }
695
+
696
+ #[test]
697
+ fn jar_collection_queries_use_unexpired_exact_scopes() {
698
+ let jar = Jar::default();
699
+ assert!(jar.is_empty());
700
+ assert_eq!(jar.len(), 0);
701
+
702
+ jar.add("first=1; Path=/one", "https://example.com/one");
703
+ jar.add("second=2; Path=/two", "https://example.com/two");
704
+
705
+ assert_eq!(jar.len(), 2);
706
+ assert!(!jar.is_empty());
707
+ assert!(jar.contains("first", "https://example.com/one"));
708
+ assert!(jar.contains("first", "https://EXAMPLE.COM/one"));
709
+ assert!(!jar.contains("first", "https://example.com/one/child"));
710
+ assert!(!jar.contains("first", "https://other.example/one"));
711
+ assert!(!jar.contains("missing", "https://example.com/one"));
712
+ assert!(!jar.contains("first", "/relative"));
713
+
714
+ jar.add("first=updated; Path=/one", "https://example.com/one");
715
+ assert_eq!(jar.len(), 2);
716
+ assert_eq!(
717
+ jar.get("first", "https://example.com/one")
718
+ .map(|cookie| cookie.value().to_owned()),
719
+ Some("updated".to_owned())
720
+ );
721
+
722
+ jar.remove("first", "https://example.com/one");
723
+ assert_eq!(jar.len(), 1);
724
+ assert!(!jar.contains("first", "https://example.com/one"));
725
+
726
+ jar.clear();
727
+ assert!(jar.is_empty());
728
+ }
729
+
730
+ #[test]
731
+ fn jar_matches_request_scope() {
732
+ let jar = Jar::default();
733
+ jar.add("host=1; Path=/", "https://example.com/");
734
+ jar.add(
735
+ "domain=2; Domain=example.com; Path=/api",
736
+ "https://example.com/api",
737
+ );
738
+ jar.add(
739
+ "secure=3; Domain=example.com; Path=/; Secure",
740
+ "https://example.com/",
741
+ );
742
+ jar.add(
743
+ "http_only=4; Domain=example.com; Path=/api; HttpOnly",
744
+ "https://example.com/api",
745
+ );
746
+ jar.add(
747
+ "admin=5; Domain=example.com; Path=/admin",
748
+ "https://example.com/admin",
749
+ );
750
+ jar.add("other=6; Domain=other.com; Path=/", "https://other.com/");
751
+
752
+ let names = |uri| {
753
+ let mut names = jar
754
+ .matches(uri)
755
+ .map(|cookie| cookie.name().to_owned())
756
+ .collect::<Vec<_>>();
757
+ names.sort();
758
+ names
759
+ };
760
+
761
+ assert_eq!(
762
+ names("https://api.example.com/api/users"),
763
+ ["domain", "http_only", "secure"]
764
+ );
765
+ assert_eq!(
766
+ names("http://api.example.com/api/users"),
767
+ ["domain", "http_only"]
768
+ );
769
+ assert_eq!(
770
+ names("https://example.com/api/users"),
771
+ ["domain", "host", "http_only", "secure"]
772
+ );
773
+ assert!(jar.matches("ftp://example.com/api/users").next().is_none());
774
+ assert!(jar.matches("/relative").next().is_none());
775
+ }
776
+
777
+ #[test]
778
+ fn jar_matches_same_name_across_domain_and_path_scopes() {
779
+ let jar = Jar::default();
780
+ jar.add("id=host; Path=/", "https://bus.example.com/");
781
+ jar.add(
782
+ "id=domain; Domain=example.com; Path=/",
783
+ "https://example.com/",
784
+ );
785
+ jar.add(
786
+ "id=path; Domain=example.com; Path=/api",
787
+ "https://example.com/api",
788
+ );
789
+
790
+ let values = |uri| {
791
+ let mut values = jar
792
+ .matches(uri)
793
+ .map(|cookie| cookie.value().to_owned())
794
+ .collect::<Vec<_>>();
795
+ values.sort();
796
+ values
797
+ };
798
+
799
+ assert_eq!(
800
+ values("https://bus.example.com/api/users"),
801
+ ["domain", "host", "path"]
802
+ );
803
+ assert_eq!(
804
+ values("https://foo.bus.example.com/api/users"),
805
+ ["domain", "path"]
806
+ );
807
+ assert_eq!(values("https://example.com/api/users"), ["domain", "path"]);
808
+ assert!(values("https://other.example/api/users").is_empty());
809
+ }
810
+
811
+ #[test]
812
+ fn jar_keeps_host_only_and_domain_cookies_with_the_same_key() {
813
+ let jar = Jar::default();
814
+ let origin = "https://example.com/";
815
+ jar.add("id=host; Path=/", origin);
816
+ jar.add("id=domain; Domain=example.com; Path=/", origin);
817
+
818
+ assert_eq!(jar.len(), 2);
819
+ assert_eq!(
820
+ jar.get("id", origin)
821
+ .map(|cookie| cookie.value().to_owned()),
822
+ Some("host".to_owned())
823
+ );
824
+
825
+ let origin_uri = Uri::from_static("https://example.com/");
826
+ match jar.cookies(&origin_uri, Version::HTTP_11) {
827
+ Cookies::Compressed(value) => assert_eq!(value, "id=host; id=domain"),
828
+ other => panic!("expected both origin cookies, got {other:?}"),
829
+ }
830
+
831
+ let subdomain = Uri::from_static("https://api.example.com/");
832
+ match jar.cookies(&subdomain, Version::HTTP_11) {
833
+ Cookies::Compressed(value) => assert_eq!(value, "id=domain"),
834
+ other => panic!("expected only the domain cookie, got {other:?}"),
835
+ }
836
+
837
+ jar.add("id=gone; Max-Age=0; Path=/", origin);
838
+ assert_eq!(jar.len(), 1);
839
+ assert!(jar.contains("id", origin));
840
+ assert_eq!(
841
+ jar.get("id", origin)
842
+ .map(|cookie| cookie.value().to_owned()),
843
+ Some("domain".to_owned())
844
+ );
845
+ match jar.cookies(&origin_uri, Version::HTTP_11) {
846
+ Cookies::Compressed(value) => assert_eq!(value, "id=domain"),
847
+ other => panic!("expected the remaining domain cookie, got {other:?}"),
848
+ }
849
+
850
+ jar.add("id=gone; Max-Age=0; Domain=example.com; Path=/", origin);
851
+ assert!(jar.is_empty());
852
+
853
+ jar.add("id=host; Path=/", origin);
854
+ jar.add("id=domain; Domain=example.com; Path=/", origin);
855
+ jar.remove("id", origin);
856
+ assert!(jar.is_empty());
857
+ }
858
+
859
+ #[test]
860
+ fn jar_matches_rfc_path_boundaries() {
861
+ let jar = Jar::default();
862
+ jar.add(
863
+ "plain=1; Domain=example.com; Path=/foo",
864
+ "https://example.com/foo",
865
+ );
866
+ jar.add(
867
+ "slash=2; Domain=example.com; Path=/foo/",
868
+ "https://example.com/foo/",
869
+ );
870
+
871
+ let names = |uri| {
872
+ let mut names = jar
873
+ .matches(uri)
874
+ .map(|cookie| cookie.name().to_owned())
875
+ .collect::<Vec<_>>();
876
+ names.sort();
877
+ names
878
+ };
879
+
880
+ assert_eq!(names("https://example.com/foo"), ["plain"]);
881
+ assert_eq!(names("https://example.com/foo/"), ["plain", "slash"]);
882
+ assert_eq!(names("https://example.com/foo/bar"), ["plain", "slash"]);
883
+ assert!(names("https://example.com/foobar").is_empty());
884
+ assert!(names("https://example.com/fo").is_empty());
885
+ assert!(names("https://example.com/").is_empty());
886
+ }
887
+
888
+ #[test]
889
+ fn jar_orders_request_cookies_by_path_length_then_creation() {
890
+ let jar = Jar::default();
891
+ let origin = "https://example.com/";
892
+
893
+ for cookie in [
894
+ "B=B1; Path=/",
895
+ "B=B2; Path=/foo",
896
+ "B=B3; Path=/foo/bar",
897
+ "A=A1; Path=/",
898
+ "A=A2; Path=/foo",
899
+ "A=A3; Path=/foo/bar",
900
+ ] {
901
+ jar.add(cookie, origin);
902
+ }
903
+
904
+ let uri = Uri::from_static("https://example.com/foo/bar/resource");
905
+ match jar.cookies(&uri, Version::HTTP_11) {
906
+ Cookies::Compressed(value) => {
907
+ assert_eq!(value, "B=B3; A=A3; B=B2; A=A2; B=B1; A=A1");
908
+ }
909
+ other => panic!("expected compressed Cookie field, got {other:?}"),
910
+ }
911
+
912
+ for version in [Version::HTTP_2, Version::HTTP_3] {
913
+ match jar.cookies(&uri, version) {
914
+ Cookies::Uncompressed(values) => {
915
+ let values = values
916
+ .iter()
917
+ .map(|value| value.to_str().unwrap())
918
+ .collect::<Vec<_>>();
919
+ assert_eq!(values, ["B=B3", "A=A3", "B=B2", "A=A2", "B=B1", "A=A1"]);
920
+ }
921
+ other => panic!("expected uncompressed Cookie fields, got {other:?}"),
922
+ }
923
+ }
924
+ }
925
+
926
+ #[test]
927
+ fn jar_inherits_creation_order_only_when_value_is_unchanged() {
928
+ let jar = Jar::default();
929
+ let origin = "https://example.com/foo";
930
+ let uri = Uri::from_static("https://example.com/foo/bar");
931
+
932
+ jar.add("B=old; Path=/foo", origin);
933
+ jar.add("A=value; Path=/foo", origin);
934
+ jar.add("B=old; Path=/foo; HttpOnly", origin);
935
+
936
+ match jar.cookies(&uri, Version::HTTP_11) {
937
+ Cookies::Compressed(value) => assert_eq!(value, "B=old; A=value"),
938
+ other => panic!("expected compressed Cookie field, got {other:?}"),
939
+ }
940
+
941
+ jar.add("B=new; Path=/foo", origin);
942
+
943
+ match jar.cookies(&uri, Version::HTTP_11) {
944
+ Cookies::Compressed(value) => assert_eq!(value, "A=value; B=new"),
945
+ other => panic!("expected compressed Cookie field, got {other:?}"),
946
+ }
947
+ }
948
+
949
+ #[test]
950
+ fn jar_sends_parent_domain_cookie_to_subdomain() {
951
+ let jar = Jar::default();
952
+ jar.add(
953
+ "session=abc; Domain=example.com; Path=/",
954
+ "http://example.com/login",
955
+ );
956
+
957
+ let should_receive = [
958
+ "http://example.com/dashboard",
959
+ "http://api.example.com/dashboard",
960
+ "http://sub.api.example.com/dashboard",
961
+ ];
962
+ for uri_str in &should_receive {
963
+ let uri = Uri::from_static(uri_str);
964
+ match jar.cookies(&uri, Version::HTTP_11) {
965
+ Cookies::Compressed(v) => assert_eq!(
966
+ v.to_str().unwrap(),
967
+ "session=abc",
968
+ "expected cookie to be sent to {uri_str}"
969
+ ),
970
+ other => panic!("expected Compressed cookie for {uri_str}, got {other:?}"),
971
+ }
972
+ }
973
+
974
+ let should_not_receive = [
975
+ "http://notexample.com/dashboard",
976
+ "http://fakeexample.com/dashboard",
977
+ ];
978
+ for uri_str in &should_not_receive {
979
+ let uri = Uri::from_static(uri_str);
980
+ assert!(
981
+ matches!(jar.cookies(&uri, Version::HTTP_11), Cookies::Empty),
982
+ "cookie must NOT be sent to {uri_str}"
983
+ );
984
+ }
985
+ }
986
+
987
+ #[test]
988
+ fn jar_does_not_send_host_only_cookie_to_subdomain() {
989
+ let jar = Jar::default();
990
+ jar.add("session=abc; Path=/", "http://example.com/login");
991
+
992
+ let origin = Uri::from_static("http://example.com/dashboard");
993
+ match jar.cookies(&origin, Version::HTTP_11) {
994
+ Cookies::Compressed(value) => assert_eq!(value, "session=abc"),
995
+ other => panic!("expected host-only cookie for origin host, got {other:?}"),
996
+ }
997
+
998
+ let subdomain = Uri::from_static("http://api.example.com/dashboard");
999
+ assert!(
1000
+ matches!(jar.cookies(&subdomain, Version::HTTP_11), Cookies::Empty),
1001
+ "host-only cookie must not be sent to a subdomain"
1002
+ );
1003
+ }
1004
+
1005
+ #[test]
1006
+ fn jar_accepts_and_normalizes_mixed_case_domain() {
1007
+ let jar = Jar::default();
1008
+ jar.add(
1009
+ "session=abc; Domain=EXAMPLE.COM; Path=/",
1010
+ "https://example.com/login",
1011
+ );
1012
+
1013
+ let cookies = jar.get_all().collect::<Vec<_>>();
1014
+ assert_eq!(cookies.len(), 1);
1015
+ assert_eq!(cookies[0].domain(), Some("example.com"));
1016
+
1017
+ let subdomain = Uri::from_static("https://api.example.com/dashboard");
1018
+ match jar.cookies(&subdomain, Version::HTTP_11) {
1019
+ Cookies::Compressed(value) => assert_eq!(value, "session=abc"),
1020
+ other => panic!("expected domain cookie for matching subdomain, got {other:?}"),
1021
+ }
1022
+ }
1023
+
1024
+ #[test]
1025
+ fn jar_ignores_leading_dot_in_domain() {
1026
+ let jar = Jar::default();
1027
+ jar.add(
1028
+ "session=abc; Domain=.example.com; Path=/",
1029
+ "https://example.com/login",
1030
+ );
1031
+
1032
+ let subdomain = Uri::from_static("https://api.example.com/dashboard");
1033
+ match jar.cookies(&subdomain, Version::HTTP_11) {
1034
+ Cookies::Compressed(value) => assert_eq!(value, "session=abc"),
1035
+ other => panic!("expected domain cookie for matching subdomain, got {other:?}"),
1036
+ }
1037
+ }
1038
+
1039
+ #[test]
1040
+ fn jar_enforces_positive_max_age_deadline() {
1041
+ let jar = Jar::default();
1042
+ let uri = Uri::from_static("http://example.com/");
1043
+ jar.add("short=lived; Max-Age=1; Path=/", &uri);
1044
+
1045
+ let cookie = jar.get("short", &uri).expect("cookie should be stored");
1046
+ assert_eq!(cookie.max_age(), None);
1047
+ assert!(cookie.expires().is_some());
1048
+ assert!(jar.contains("short", &uri));
1049
+ assert_eq!(jar.len(), 1);
1050
+ assert!(!jar.is_empty());
1051
+ assert_eq!(jar.matches(&uri).count(), 1);
1052
+ assert_eq!(jar.get_all().count(), 1);
1053
+ assert!(matches!(
1054
+ jar.cookies(&uri, Version::HTTP_11),
1055
+ Cookies::Compressed(_)
1056
+ ));
1057
+
1058
+ thread::sleep(StdDuration::from_millis(1100));
1059
+
1060
+ assert!(jar.get("short", &uri).is_none());
1061
+ assert!(!jar.contains("short", &uri));
1062
+ assert_eq!(jar.len(), 0);
1063
+ assert!(jar.is_empty());
1064
+ assert_eq!(jar.matches(&uri).count(), 0);
1065
+ assert_eq!(jar.get_all().count(), 0);
1066
+ assert!(matches!(
1067
+ jar.cookies(&uri, Version::HTTP_11),
1068
+ Cookies::Empty
1069
+ ));
1070
+ }
1071
+
1072
+ #[test]
1073
+ fn jar_removes_non_positive_max_age() {
1074
+ let jar = Jar::default();
1075
+ let uri = "http://example.com/";
1076
+
1077
+ jar.add("zero=old; Path=/", uri);
1078
+ jar.add("zero=gone; Max-Age=0; Path=/", uri);
1079
+ assert!(jar.get("zero", uri).is_none());
1080
+
1081
+ jar.add("negative=old; Path=/", uri);
1082
+ jar.add("negative=gone; Max-Age=-10; Path=/", uri);
1083
+ assert!(jar.get("negative", uri).is_none());
1084
+ }
1085
+
1086
+ #[test]
1087
+ fn jar_max_age_overrides_expires_in_either_order() {
1088
+ let jar = Jar::default();
1089
+ let uri = "http://example.com/";
1090
+
1091
+ jar.add(
1092
+ "first=kept; Max-Age=60; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/",
1093
+ uri,
1094
+ );
1095
+ jar.add(
1096
+ "last=kept; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=60; Path=/",
1097
+ uri,
1098
+ );
1099
+ assert!(jar.get("first", uri).is_some());
1100
+ assert!(jar.get("last", uri).is_some());
1101
+
1102
+ jar.add("remove_first=old; Path=/", uri);
1103
+ jar.add(
1104
+ "remove_first=gone; Max-Age=0; Expires=Fri, 31 Dec 9999 23:59:59 GMT; Path=/",
1105
+ uri,
1106
+ );
1107
+ jar.add("remove_last=old; Path=/", uri);
1108
+ jar.add(
1109
+ "remove_last=gone; Expires=Fri, 31 Dec 9999 23:59:59 GMT; Max-Age=0; Path=/",
1110
+ uri,
1111
+ );
1112
+ assert!(jar.get("remove_first", uri).is_none());
1113
+ assert!(jar.get("remove_last", uri).is_none());
1114
+ }
1115
+
1116
+ #[test]
1117
+ fn jar_uses_last_valid_max_age() {
1118
+ let jar = Jar::default();
1119
+ let uri = "http://example.com/";
1120
+
1121
+ jar.add("kept=value; Max-Age=0; Max-Age=60; Path=/", uri);
1122
+ assert!(jar.get("kept", uri).is_some());
1123
+
1124
+ jar.add("removed=old; Path=/", uri);
1125
+ jar.add("removed=gone; Max-Age=60; Max-Age=0; Path=/", uri);
1126
+ assert!(jar.get("removed", uri).is_none());
1127
+
1128
+ jar.add(
1129
+ "malformed=kept; Max-Age=60; Max-Age=invalid; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/",
1130
+ uri,
1131
+ );
1132
+ assert!(jar.get("malformed", uri).is_some());
1133
+ }
1134
+
1135
+ #[test]
1136
+ fn jar_ignores_malformed_max_age() {
1137
+ let jar = Jar::default();
1138
+ let uri = "http://example.com/";
1139
+
1140
+ jar.add(
1141
+ "persistent=value; Max-Age=invalid; Expires=Fri, 31 Dec 9999 23:59:59 GMT; Path=/",
1142
+ uri,
1143
+ );
1144
+ assert!(jar.get("persistent", uri).is_some());
1145
+
1146
+ jar.add(
1147
+ "expired=value; Max-Age=invalid; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/",
1148
+ uri,
1149
+ );
1150
+ assert!(jar.get("expired", uri).is_none());
1151
+
1152
+ jar.add(
1153
+ "plus=value; Max-Age=+60; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/",
1154
+ uri,
1155
+ );
1156
+ assert!(jar.get("plus", uri).is_none());
1157
+
1158
+ jar.add("session=value; Max-Age=invalid; Path=/", uri);
1159
+ let session = jar
1160
+ .get("session", uri)
1161
+ .expect("session cookie should be stored");
1162
+ assert_eq!(session.max_age(), None);
1163
+ assert_eq!(session.expires(), None);
1164
+ }
1165
+
1166
+ #[test]
1167
+ fn jar_does_not_suffix_match_ipv4_addresses() {
1168
+ let jar = Jar::default();
1169
+ jar.add("session=abc; Domain=0.1; Path=/", "http://192.168.0.1/");
1170
+
1171
+ assert_eq!(jar.get_all().count(), 0);
1172
+
1173
+ let unrelated = Uri::from_static("http://10.0.0.1/");
1174
+ assert!(matches!(
1175
+ jar.cookies(&unrelated, Version::HTTP_11),
1176
+ Cookies::Empty
1177
+ ));
1178
+ }
1179
+
1180
+ #[test]
1181
+ fn jar_preserves_ipv6_host_identity() {
1182
+ let jar = Jar::default();
1183
+ jar.add("session=abc; Path=/", "http://[2001:db8::1]/");
1184
+
1185
+ let equivalent = Uri::from_static("http://[2001:0db8:0:0:0:0:0:1]/");
1186
+ match jar.cookies(&equivalent, Version::HTTP_11) {
1187
+ Cookies::Compressed(value) => assert_eq!(value, "session=abc"),
1188
+ other => panic!("expected cookie for equivalent IPv6 host, got {other:?}"),
1189
+ }
1190
+
1191
+ let unrelated = Uri::from_static("http://[2001:db8::2]/");
1192
+ assert!(matches!(
1193
+ jar.cookies(&unrelated, Version::HTTP_11),
1194
+ Cookies::Empty
1195
+ ));
1196
+ }
1197
+
1198
+ #[test]
1199
+ fn jar_subdomain_cookie_does_not_leak_to_parent_or_sibling() {
1200
+ let jar = Jar::default();
1201
+ jar.add(
1202
+ "token=xyz; Domain=api.example.com; Path=/",
1203
+ "http://api.example.com/",
1204
+ );
1205
+
1206
+ let uri = Uri::from_static("http://api.example.com/");
1207
+ assert!(
1208
+ matches!(jar.cookies(&uri, Version::HTTP_11), Cookies::Compressed(_)),
1209
+ "cookie must be sent to api.example.com"
1210
+ );
1211
+
1212
+ let must_not_receive = [
1213
+ "http://example.com/",
1214
+ "http://other.example.com/",
1215
+ "http://notapi.example.com/",
1216
+ ];
1217
+ for uri_str in &must_not_receive {
1218
+ let uri = Uri::from_static(uri_str);
1219
+ assert!(
1220
+ matches!(jar.cookies(&uri, Version::HTTP_11), Cookies::Empty),
1221
+ "cookie must NOT leak to {uri_str}"
1222
+ );
1223
+ }
1224
+ }
1225
+ }