1#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct Cookie {
13 pub name: String,
14 pub value: String,
15 pub domain: String,
16 pub path: String,
17 pub secure: bool,
18 pub http_only: bool,
19 pub expires: Option<f64>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub same_site: Option<SameSite>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub url: Option<String>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub enum SameSite {
33 Strict,
34 Lax,
35 None,
36}
37
38impl SameSite {
39 #[must_use]
41 pub fn as_str(self) -> &'static str {
42 match self {
43 Self::Strict => "Strict",
44 Self::Lax => "Lax",
45 Self::None => "None",
46 }
47 }
48}
49
50impl std::str::FromStr for SameSite {
51 type Err = ();
52
53 fn from_str(s: &str) -> Result<Self, Self::Err> {
54 match s {
55 "Strict" => Ok(Self::Strict),
56 "Lax" => Ok(Self::Lax),
57 "None" => Ok(Self::None),
58 _ => Err(()),
59 }
60 }
61}
62
63const MAX_COOKIE_EXPIRES_SECONDS: f64 = 253_402_300_799.0;
66
67fn is_local_hostname(hostname: &str) -> bool {
70 hostname == "localhost" || hostname.ends_with(".localhost")
71}
72
73fn cookie_domain_matches(hostname: &str, domain: &str) -> bool {
77 if hostname == domain {
78 return true;
79 }
80 if !domain.starts_with('.') {
81 return false;
82 }
83 format!(".{hostname}").ends_with(domain)
84}
85
86fn cookie_path_matches(request_path: &str, cookie_path: &str) -> bool {
88 if request_path == cookie_path {
89 return true;
90 }
91 let mut value = request_path.to_string();
92 if !value.ends_with('/') {
93 value.push('/');
94 }
95 let mut path = cookie_path.to_string();
96 if !path.ends_with('/') {
97 path.push('/');
98 }
99 value.starts_with(&path)
100}
101
102#[must_use]
107pub fn cookie_matches_url(cookie: &Cookie, url: &reqwest::Url) -> bool {
108 let hostname = url.host_str().unwrap_or("");
109 if cookie.secure && url.scheme() != "https" && !is_local_hostname(hostname) {
110 return false;
111 }
112 if !cookie_domain_matches(hostname, &cookie.domain) {
113 return false;
114 }
115 if !cookie_path_matches(url.path(), &cookie.path) {
116 return false;
117 }
118 if let Some(expires) = cookie.expires
119 && expires >= 0.0
120 && expires
121 < std::time::SystemTime::now()
122 .duration_since(std::time::UNIX_EPOCH)
123 .map_or(0.0, |d| d.as_secs_f64())
124 {
125 return false;
126 }
127 true
128}
129
130fn parse_raw_set_cookie(header: &str) -> Option<Cookie> {
135 let mut pairs = header.split(';').filter(|s| !s.trim().is_empty()).map(|p| {
136 p.split_once('=').map_or_else(
137 || (p.trim().to_string(), String::new()),
138 |(k, v)| (k.trim().to_string(), v.trim().to_string()),
139 )
140 });
141 let (name, value) = pairs.next()?;
142 let mut cookie = Cookie {
143 name,
144 value,
145 domain: String::new(),
146 path: String::new(),
147 secure: false,
148 http_only: false,
149 expires: None,
150 same_site: Some(SameSite::Lax),
153 url: None,
154 };
155 for (attr, attr_value) in pairs {
156 match attr.to_ascii_lowercase().as_str() {
157 "expires" => {
158 if let Ok(when) = httpdate::parse_http_date(&attr_value) {
161 let secs = when
162 .duration_since(std::time::UNIX_EPOCH)
163 .map_or(0.0, |d| d.as_secs_f64());
164 cookie.expires = Some(secs.min(MAX_COOKIE_EXPIRES_SECONDS));
165 }
166 },
167 "max-age" => {
168 if let Ok(delta) = attr_value.parse::<i64>() {
170 if delta <= 0 {
171 cookie.expires = Some(0.0);
172 } else {
173 let now = std::time::SystemTime::now()
174 .duration_since(std::time::UNIX_EPOCH)
175 .map_or(0.0, |d| d.as_secs_f64());
176 let delta = f64::from(u32::try_from(delta).unwrap_or(u32::MAX));
179 cookie.expires = Some((now + delta).min(MAX_COOKIE_EXPIRES_SECONDS));
180 }
181 }
182 },
183 "domain" => {
184 let mut domain = attr_value.to_ascii_lowercase();
185 if !domain.is_empty() && !domain.starts_with('.') && domain.contains('.') {
188 domain.insert(0, '.');
189 }
190 cookie.domain = domain;
191 },
192 "path" => cookie.path = attr_value,
193 "secure" => cookie.secure = true,
194 "httponly" => cookie.http_only = true,
195 "samesite" => {
196 cookie.same_site = match attr_value.to_ascii_lowercase().as_str() {
197 "none" => Some(SameSite::None),
198 "strict" => Some(SameSite::Strict),
199 "lax" => Some(SameSite::Lax),
200 _ => cookie.same_site,
201 };
202 },
203 _ => {},
204 }
205 }
206 Some(cookie)
207}
208
209pub fn parse_set_cookie_headers(response_url: &reqwest::Url, headers: &reqwest::header::HeaderMap) -> Vec<Cookie> {
214 let hostname = response_url.host_str().unwrap_or("");
215 let path = response_url.path();
217 let default_path = {
218 let trimmed = path.strip_prefix('/').unwrap_or(path);
219 let segments: Vec<&str> = trimmed.split('/').collect();
220 format!("/{}", segments[..segments.len().saturating_sub(1)].join("/"))
221 };
222 let mut cookies = Vec::new();
223 for value in headers.get_all(reqwest::header::SET_COOKIE) {
224 let Ok(raw) = value.to_str() else { continue };
225 let Some(mut cookie) = parse_raw_set_cookie(raw) else {
226 continue;
227 };
228 if cookie.domain.is_empty() {
229 cookie.domain = hostname.to_string();
231 }
232 if !cookie_domain_matches(hostname, &cookie.domain) {
233 continue;
234 }
235 if cookie.path.is_empty() || !cookie.path.starts_with('/') {
236 cookie.path.clone_from(&default_path);
237 }
238 cookies.push(cookie);
239 }
240 cookies
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn parse(header: &str, url: &str) -> Option<Cookie> {
248 let url = reqwest::Url::parse(url).unwrap();
249 let mut headers = reqwest::header::HeaderMap::new();
250 headers.append(reqwest::header::SET_COOKIE, header.parse().unwrap());
251 parse_set_cookie_headers(&url, &headers).into_iter().next()
252 }
253
254 #[test]
255 fn host_only_defaults_from_response_url() {
256 let c = parse("sid=abc", "http://example.com/a/b/c").unwrap();
257 assert_eq!(c.name, "sid");
258 assert_eq!(c.value, "abc");
259 assert_eq!(c.domain, "example.com");
261 assert_eq!(c.path, "/a/b");
263 assert_eq!(c.expires, None);
264 assert!(matches!(c.same_site, Some(SameSite::Lax)));
265 }
266
267 #[test]
268 fn declared_domain_gets_dot_prefixed() {
269 let c = parse("sid=1; Domain=example.com; Path=/", "http://example.com/").unwrap();
270 assert_eq!(c.domain, ".example.com");
271 assert_eq!(c.path, "/");
272 }
273
274 #[test]
275 fn foreign_domain_is_dropped() {
276 assert!(parse("sid=1; Domain=evil.com", "http://example.com/").is_none());
277 }
278
279 #[test]
280 fn attributes_parse() {
281 let c = parse(
282 "a=b; Secure; HttpOnly; SameSite=Strict; Max-Age=3600; Path=/x",
283 "https://example.com/",
284 )
285 .unwrap();
286 assert!(c.secure);
287 assert!(c.http_only);
288 assert!(matches!(c.same_site, Some(SameSite::Strict)));
289 assert_eq!(c.path, "/x");
290 let now = std::time::SystemTime::now()
291 .duration_since(std::time::UNIX_EPOCH)
292 .unwrap()
293 .as_secs_f64();
294 let exp = c.expires.unwrap();
295 assert!(exp > now + 3500.0 && exp < now + 3700.0);
296 }
297
298 #[test]
299 fn non_positive_max_age_expires_immediately() {
300 let c = parse("a=b; Max-Age=0; Path=/", "http://example.com/").unwrap();
301 assert_eq!(c.expires, Some(0.0));
302 let c = parse("a=b; Max-Age=-5; Path=/", "http://example.com/").unwrap();
303 assert_eq!(c.expires, Some(0.0));
304 }
305
306 #[test]
307 fn expires_attribute_parses_http_date() {
308 let c = parse(
309 "a=b; Expires=Wed, 01 Jan 2031 00:00:00 GMT; Path=/",
310 "http://example.com/",
311 )
312 .unwrap();
313 let exp = c.expires.unwrap();
314 assert!((1_924_991_940.0..1_924_993_000.0).contains(&exp), "got {exp}");
315 }
316
317 #[test]
318 fn matcher_domain_and_path() {
319 let mk = |domain: &str, path: &str, secure: bool| Cookie {
320 name: "n".into(),
321 value: "v".into(),
322 domain: domain.into(),
323 path: path.into(),
324 secure,
325 http_only: false,
326 expires: None,
327 same_site: None,
328 url: None,
329 };
330 let url = |u: &str| reqwest::Url::parse(u).unwrap();
331 assert!(cookie_matches_url(
333 &mk("example.com", "/", false),
334 &url("http://example.com/")
335 ));
336 assert!(!cookie_matches_url(
337 &mk("example.com", "/", false),
338 &url("http://sub.example.com/")
339 ));
340 assert!(cookie_matches_url(
342 &mk(".example.com", "/", false),
343 &url("http://sub.example.com/")
344 ));
345 assert!(cookie_matches_url(
346 &mk(".example.com", "/", false),
347 &url("http://example.com/")
348 ));
349 assert!(cookie_matches_url(&mk("e.com", "/a", false), &url("http://e.com/a/b")));
351 assert!(!cookie_matches_url(&mk("e.com", "/a", false), &url("http://e.com/ab")));
352 assert!(!cookie_matches_url(&mk("e.com", "/", true), &url("http://e.com/")));
354 assert!(cookie_matches_url(&mk("e.com", "/", true), &url("https://e.com/")));
355 assert!(cookie_matches_url(
356 &mk("localhost", "/", true),
357 &url("http://localhost/")
358 ));
359 }
360
361 #[test]
362 fn expired_cookie_is_not_sent() {
363 let c = Cookie {
364 name: "n".into(),
365 value: "v".into(),
366 domain: "e.com".into(),
367 path: "/".into(),
368 secure: false,
369 http_only: false,
370 expires: Some(1.0),
371 same_site: None,
372 url: None,
373 };
374 assert!(!cookie_matches_url(&c, &reqwest::Url::parse("http://e.com/").unwrap()));
375 let session = Cookie {
377 expires: Some(-1.0),
378 ..c
379 };
380 assert!(cookie_matches_url(
381 &session,
382 &reqwest::Url::parse("http://e.com/").unwrap()
383 ));
384 }
385}