Skip to main content

oxihttp_core/
cookie.rs

1//! Cookie parsing and management for the OxiHTTP stack.
2//!
3//! Provides a `Cookie` struct for individual cookie values and a `CookieJar`
4//! for managing cookies across multiple requests and responses.
5
6use std::fmt;
7use std::time::Duration;
8
9/// Represents a single HTTP cookie.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Cookie {
12    pub name: String,
13    pub value: String,
14    pub domain: Option<String>,
15    pub path: Option<String>,
16    /// Max-Age attribute stored as a Duration.
17    pub max_age: Option<Duration>,
18    pub secure: bool,
19    pub http_only: bool,
20    pub same_site: Option<SameSite>,
21    /// Computed absolute expiry time from max_age, set at insertion.
22    pub expires_at: Option<std::time::Instant>,
23}
24
25/// The SameSite cookie attribute.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SameSite {
28    /// Cookies are sent with same-site requests only.
29    Strict,
30    /// Cookies are sent with same-site requests and top-level navigations.
31    Lax,
32    /// Cookies are sent with all requests (requires Secure).
33    None,
34}
35
36impl Cookie {
37    /// Create a new cookie with the given name and value.
38    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
39        Self {
40            name: name.into(),
41            value: value.into(),
42            domain: None,
43            path: None,
44            max_age: None,
45            secure: false,
46            http_only: false,
47            same_site: None,
48            expires_at: None,
49        }
50    }
51
52    /// The cookie name.
53    pub fn name(&self) -> &str {
54        &self.name
55    }
56
57    /// The cookie value.
58    pub fn value(&self) -> &str {
59        &self.value
60    }
61
62    /// The domain attribute.
63    pub fn domain(&self) -> Option<&str> {
64        self.domain.as_deref()
65    }
66
67    /// The path attribute.
68    pub fn path(&self) -> Option<&str> {
69        self.path.as_deref()
70    }
71
72    /// The max-age attribute as a `Duration`.
73    pub fn max_age(&self) -> Option<Duration> {
74        self.max_age
75    }
76
77    /// Whether the Secure flag is set.
78    pub fn is_secure(&self) -> bool {
79        self.secure
80    }
81
82    /// Whether the HttpOnly flag is set.
83    pub fn is_http_only(&self) -> bool {
84        self.http_only
85    }
86
87    /// The SameSite attribute.
88    pub fn same_site(&self) -> Option<SameSite> {
89        self.same_site
90    }
91
92    /// Set the domain attribute.
93    pub fn set_domain(mut self, domain: impl Into<String>) -> Self {
94        self.domain = Some(domain.into());
95        self
96    }
97
98    /// Set the path attribute.
99    pub fn set_path(mut self, path: impl Into<String>) -> Self {
100        self.path = Some(path.into());
101        self
102    }
103
104    /// Set the max-age attribute in seconds.
105    pub fn set_max_age(mut self, seconds: u64) -> Self {
106        self.max_age = Some(Duration::from_secs(seconds));
107        self
108    }
109
110    /// Set the Secure flag.
111    pub fn set_secure(mut self, secure: bool) -> Self {
112        self.secure = secure;
113        self
114    }
115
116    /// Set the HttpOnly flag.
117    pub fn set_http_only(mut self, http_only: bool) -> Self {
118        self.http_only = http_only;
119        self
120    }
121
122    /// Set the SameSite attribute.
123    pub fn set_same_site(mut self, same_site: SameSite) -> Self {
124        self.same_site = Some(same_site);
125        self
126    }
127
128    /// Parse a cookie from a `Set-Cookie` header value (RFC 6265).
129    /// `expires_at` is left as `None` here; it is computed at insertion time by `CookieJar`.
130    pub fn parse_set_cookie(header: &str) -> Option<Self> {
131        let mut parts = header.split(';');
132        let first = parts.next()?.trim();
133        let (name, value) = first.split_once('=')?;
134        let name = name.trim();
135        let value = value.trim();
136
137        if name.is_empty() {
138            return None;
139        }
140
141        let mut cookie = Cookie::new(name, value);
142
143        for attr in parts {
144            let attr = attr.trim();
145            if attr.is_empty() {
146                continue;
147            }
148            if let Some((key, val)) = attr.split_once('=') {
149                let key = key.trim().to_lowercase();
150                let val = val.trim();
151                match key.as_str() {
152                    "domain" => cookie.domain = Some(val.to_string()),
153                    "path" => cookie.path = Some(val.to_string()),
154                    "max-age" => {
155                        cookie.max_age = val.parse::<u64>().ok().map(Duration::from_secs);
156                    }
157                    "samesite" => {
158                        cookie.same_site = match val.to_lowercase().as_str() {
159                            "strict" => Some(SameSite::Strict),
160                            "lax" => Some(SameSite::Lax),
161                            "none" => Some(SameSite::None),
162                            _ => None,
163                        };
164                    }
165                    _ => {}
166                }
167            } else {
168                match attr.to_lowercase().as_str() {
169                    "secure" => cookie.secure = true,
170                    "httponly" => cookie.http_only = true,
171                    _ => {}
172                }
173            }
174        }
175
176        Some(cookie)
177    }
178
179    /// Serialize the cookie for use in a `Cookie` request header.
180    pub fn to_cookie_header(&self) -> String {
181        format!("{}={}", self.name, self.value)
182    }
183}
184
185impl fmt::Display for Cookie {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        write!(f, "{}={}", self.name, self.value)?;
188        if let Some(ref domain) = self.domain {
189            write!(f, "; Domain={domain}")?;
190        }
191        if let Some(ref path) = self.path {
192            write!(f, "; Path={path}")?;
193        }
194        if let Some(max_age) = self.max_age {
195            write!(f, "; Max-Age={}", max_age.as_secs())?;
196        }
197        if self.secure {
198            write!(f, "; Secure")?;
199        }
200        if self.http_only {
201            write!(f, "; HttpOnly")?;
202        }
203        if let Some(same_site) = self.same_site {
204            match same_site {
205                SameSite::Strict => write!(f, "; SameSite=Strict")?,
206                SameSite::Lax => write!(f, "; SameSite=Lax")?,
207                SameSite::None => write!(f, "; SameSite=None")?,
208            }
209        }
210        Ok(())
211    }
212}
213
214// ---------------------------------------------------------------------------
215// RFC 6265 helpers
216// ---------------------------------------------------------------------------
217
218/// RFC 6265 §5.1.3 domain-match
219fn domain_match(cookie_domain: &str, request_host: &str) -> bool {
220    let cd = cookie_domain.to_lowercase();
221    let rh_full = request_host.to_lowercase();
222    // Strip port from request_host if present
223    let rh = rh_full.split(':').next().unwrap_or(&rh_full);
224    // Remove leading dot from cookie domain
225    let cd = cd.strip_prefix('.').unwrap_or(&cd);
226    // Exact match
227    if rh == cd {
228        return true;
229    }
230    // Suffix match: host ends with "." + cookie_domain, and not an IP
231    if rh.ends_with(&format!(".{cd}")) {
232        // Ensure cookie_domain is not an IP literal
233        return cd.parse::<std::net::IpAddr>().is_err();
234    }
235    false
236}
237
238/// RFC 6265 §5.1.4 path-match
239fn path_match(cookie_path: &str, request_path: &str) -> bool {
240    if request_path == cookie_path {
241        return true;
242    }
243    if let Some(remaining) = request_path.strip_prefix(cookie_path) {
244        // Next char must be '/' or cookie_path ends with '/'
245        return remaining.starts_with('/') || cookie_path.ends_with('/');
246    }
247    false
248}
249
250// ---------------------------------------------------------------------------
251// CookieJar
252// ---------------------------------------------------------------------------
253
254/// A jar for collecting and managing cookies across requests and responses.
255#[derive(Debug, Clone, Default)]
256pub struct CookieJar {
257    pub cookies: Vec<Cookie>,
258}
259
260impl CookieJar {
261    /// Create an empty cookie jar.
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Add or replace a cookie in the jar (keyed by name only, for backward compat).
267    pub fn insert(&mut self, cookie: Cookie) {
268        self.cookies.retain(|c| c.name != cookie.name);
269        self.cookies.push(cookie);
270    }
271
272    /// Get a cookie by name (first match).
273    pub fn get(&self, name: &str) -> Option<&Cookie> {
274        self.cookies.iter().find(|c| c.name == name)
275    }
276
277    /// Remove a cookie by name. Returns the removed cookie if it existed.
278    pub fn remove(&mut self, name: &str) -> Option<Cookie> {
279        if let Some(pos) = self.cookies.iter().position(|c| c.name == name) {
280            Some(self.cookies.remove(pos))
281        } else {
282            None
283        }
284    }
285
286    /// Iterate over all cookies.
287    pub fn iter(&self) -> impl Iterator<Item = &Cookie> {
288        self.cookies.iter()
289    }
290
291    /// The number of cookies in the jar.
292    pub fn len(&self) -> usize {
293        self.cookies.len()
294    }
295
296    /// Returns `true` if the jar is empty.
297    pub fn is_empty(&self) -> bool {
298        self.cookies.is_empty()
299    }
300
301    /// Build a `Cookie` header value from all cookies in the jar.
302    pub fn to_cookie_header(&self) -> String {
303        self.cookies
304            .iter()
305            .map(|c| c.to_cookie_header())
306            .collect::<Vec<_>>()
307            .join("; ")
308    }
309
310    /// Parse cookies from multiple `Set-Cookie` header values and add them to the jar.
311    pub fn add_from_set_cookie_headers<'a, I: IntoIterator<Item = &'a str>>(&mut self, headers: I) {
312        for header in headers {
313            if let Some(cookie) = Cookie::parse_set_cookie(header) {
314                self.insert(cookie);
315            }
316        }
317    }
318
319    /// Insert a cookie with URL context for default domain/path assignment and expiry computation.
320    pub fn insert_for_url(&mut self, mut cookie: Cookie, request_url: &http::Uri) {
321        // Default domain = request host when cookie has no Domain attr
322        if cookie.domain.is_none() {
323            if let Some(host) = request_url.host() {
324                cookie.domain = Some(host.split(':').next().unwrap_or(host).to_lowercase());
325            }
326        } else {
327            // Normalize: remove leading dot
328            if let Some(d) = &cookie.domain {
329                cookie.domain = Some(d.trim_start_matches('.').to_lowercase());
330            }
331        }
332        // Default path = up to last '/' in request path
333        if cookie.path.is_none() {
334            let req_path = request_url.path();
335            let default_path = if let Some(pos) = req_path.rfind('/') {
336                if pos == 0 {
337                    "/"
338                } else {
339                    &req_path[..pos]
340                }
341            } else {
342                "/"
343            };
344            cookie.path = Some(default_path.to_string());
345        }
346        // Compute expires_at from max_age
347        cookie.expires_at = cookie.max_age.map(|dur| std::time::Instant::now() + dur);
348
349        // Dedup by (name, domain, path)
350        let name = cookie.name.clone();
351        let domain = cookie.domain.clone();
352        let path = cookie.path.clone();
353        self.cookies
354            .retain(|c| !(c.name == name && c.domain == domain && c.path == path));
355        self.cookies.push(cookie);
356    }
357
358    /// Returns cookies matching the given URL per RFC 6265 §5.4.
359    /// Filters by domain-match, path-match, secure, and expiry.
360    /// Result is sorted by path length descending.
361    pub fn cookies_for_url(&self, url: &http::Uri) -> Vec<&Cookie> {
362        let now = std::time::Instant::now();
363        let host = url.host().unwrap_or("");
364        let path = url.path();
365        let is_secure = url.scheme_str() == Some("https");
366
367        let mut matched: Vec<&Cookie> = self
368            .cookies
369            .iter()
370            .filter(|c| {
371                // Expiry check
372                if let Some(exp) = c.expires_at {
373                    if exp <= now {
374                        return false;
375                    }
376                }
377                // Secure flag: secure cookies only for https
378                if c.secure && !is_secure {
379                    return false;
380                }
381                // Domain match
382                let cookie_domain = c.domain.as_deref().unwrap_or("");
383                if !cookie_domain.is_empty() && !domain_match(cookie_domain, host) {
384                    return false;
385                }
386                // Path match
387                let cookie_path = c.path.as_deref().unwrap_or("/");
388                if !path_match(cookie_path, path) {
389                    return false;
390                }
391                true
392            })
393            .collect();
394
395        // Sort by path length descending (longer path = more specific = first)
396        matched.sort_by(|a, b| {
397            let a_len = a.path.as_deref().unwrap_or("/").len();
398            let b_len = b.path.as_deref().unwrap_or("/").len();
399            b_len.cmp(&a_len)
400        });
401        matched
402    }
403
404    /// Build a `Cookie` header value for the given URL, or None if no cookies match.
405    pub fn to_cookie_header_for_url(&self, url: &http::Uri) -> Option<String> {
406        let matched = self.cookies_for_url(url);
407        if matched.is_empty() {
408            return None;
409        }
410        Some(
411            matched
412                .iter()
413                .map(|c| format!("{}={}", c.name, c.value))
414                .collect::<Vec<_>>()
415                .join("; "),
416        )
417    }
418
419    /// Parse all `Set-Cookie` headers from an `http::HeaderMap` and insert them
420    /// with URL context for default domain/path and expiry computation.
421    pub fn add_from_response_headers(&mut self, headers: &http::HeaderMap, url: &http::Uri) {
422        for value in headers.get_all(http::header::SET_COOKIE) {
423            if let Ok(s) = value.to_str() {
424                if let Some(cookie) = Cookie::parse_set_cookie(s) {
425                    self.insert_for_url(cookie, url);
426                }
427            }
428        }
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_parse_simple_cookie() {
438        let cookie = Cookie::parse_set_cookie("session=abc123").expect("parse cookie");
439        assert_eq!(cookie.name(), "session");
440        assert_eq!(cookie.value(), "abc123");
441    }
442
443    #[test]
444    fn test_parse_full_cookie() {
445        let cookie = Cookie::parse_set_cookie(
446            "id=a3fWa; Domain=.example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax",
447        )
448        .expect("parse full cookie");
449        assert_eq!(cookie.name(), "id");
450        assert_eq!(cookie.value(), "a3fWa");
451        assert_eq!(cookie.domain(), Some(".example.com"));
452        assert_eq!(cookie.path(), Some("/"));
453        assert_eq!(cookie.max_age(), Some(Duration::from_secs(3600)));
454        assert!(cookie.is_secure());
455        assert!(cookie.is_http_only());
456        assert_eq!(cookie.same_site(), Some(SameSite::Lax));
457    }
458
459    #[test]
460    fn test_cookie_display() {
461        let cookie = Cookie::new("session", "abc")
462            .set_domain(".example.com")
463            .set_path("/")
464            .set_secure(true)
465            .set_http_only(true);
466        let s = cookie.to_string();
467        assert!(s.contains("session=abc"));
468        assert!(s.contains("Domain=.example.com"));
469        assert!(s.contains("Secure"));
470        assert!(s.contains("HttpOnly"));
471    }
472
473    #[test]
474    fn test_cookie_jar_operations() {
475        let mut jar = CookieJar::new();
476        assert!(jar.is_empty());
477
478        jar.insert(Cookie::new("a", "1"));
479        jar.insert(Cookie::new("b", "2"));
480        assert_eq!(jar.len(), 2);
481
482        assert_eq!(jar.get("a").map(|c| c.value()), Some("1"));
483        jar.remove("a");
484        assert_eq!(jar.len(), 1);
485        assert!(jar.get("a").is_none());
486    }
487
488    #[test]
489    fn test_cookie_jar_header() {
490        let mut jar = CookieJar::new();
491        jar.insert(Cookie::new("a", "1"));
492        jar.insert(Cookie::new("b", "2"));
493        let header = jar.to_cookie_header();
494        // Order not guaranteed, but both must be present
495        assert!(header.contains("a=1"));
496        assert!(header.contains("b=2"));
497    }
498
499    #[test]
500    fn test_add_from_set_cookie_headers() {
501        let mut jar = CookieJar::new();
502        jar.add_from_set_cookie_headers(vec!["session=abc; HttpOnly", "lang=en; Path=/"]);
503        assert_eq!(jar.len(), 2);
504        assert!(jar.get("session").expect("session").is_http_only());
505        assert_eq!(jar.get("lang").expect("lang").path(), Some("/"));
506    }
507
508    #[test]
509    fn test_empty_name_rejected() {
510        let result = Cookie::parse_set_cookie("=value");
511        assert!(result.is_none());
512    }
513
514    #[test]
515    fn test_domain_match_exact() {
516        assert!(domain_match("example.com", "example.com"));
517        assert!(domain_match(".example.com", "example.com")); // leading dot stripped
518        assert!(!domain_match("example.com", "other.com"));
519    }
520
521    #[test]
522    fn test_domain_match_suffix() {
523        assert!(domain_match("example.com", "sub.example.com"));
524        assert!(domain_match(".example.com", "sub.example.com"));
525        assert!(!domain_match("example.com", "notexample.com"));
526    }
527
528    #[test]
529    fn test_path_match() {
530        assert!(path_match("/", "/foo/bar"));
531        assert!(path_match("/foo", "/foo/bar"));
532        assert!(path_match("/foo/", "/foo/bar"));
533        assert!(!path_match("/foo", "/foobar")); // must be boundary
534        assert!(path_match("/foo", "/foo"));
535    }
536
537    #[test]
538    fn test_insert_for_url_defaults() {
539        use http::Uri;
540        let url: Uri = "http://example.com/api/v1/items".parse().expect("uri");
541        let cookie = Cookie::new("session", "abc");
542        let mut jar = CookieJar::new();
543        jar.insert_for_url(cookie, &url);
544        assert_eq!(jar.cookies[0].domain.as_deref(), Some("example.com"));
545        assert_eq!(jar.cookies[0].path.as_deref(), Some("/api/v1"));
546    }
547
548    #[test]
549    fn test_cookies_for_url() {
550        use http::Uri;
551        let url: Uri = "http://example.com/api/items".parse().expect("uri");
552        let mut jar = CookieJar::new();
553        let c = Cookie::new("session", "abc");
554        jar.insert_for_url(c, &url);
555
556        let matches = jar.cookies_for_url(&url);
557        assert_eq!(matches.len(), 1);
558        assert_eq!(matches[0].name, "session");
559
560        // Different domain — no match
561        let other_url: Uri = "http://other.com/api/items".parse().expect("uri");
562        let no_matches = jar.cookies_for_url(&other_url);
563        assert!(no_matches.is_empty());
564    }
565
566    #[test]
567    fn test_expired_cookie_not_returned() {
568        use http::Uri;
569        let url: Uri = "http://example.com/".parse().expect("uri");
570        let mut c = Cookie::new("old", "val");
571        c.max_age = Some(std::time::Duration::from_secs(0)); // immediately expired
572        let mut jar = CookieJar::new();
573        jar.insert_for_url(c, &url);
574        // Manually set expires_at to the past
575        if let Some(cookie) = jar.cookies.first_mut() {
576            cookie.expires_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
577        }
578        assert!(jar.cookies_for_url(&url).is_empty());
579    }
580
581    #[test]
582    fn test_secure_cookie_https_only() {
583        use http::Uri;
584        let https_url: Uri = "https://example.com/".parse().expect("uri");
585        let http_url: Uri = "http://example.com/".parse().expect("uri");
586        let mut c = Cookie::new("secure_token", "xyz");
587        c.secure = true;
588        let mut jar = CookieJar::new();
589        jar.insert_for_url(c, &https_url);
590        // Only returned for https
591        assert_eq!(jar.cookies_for_url(&https_url).len(), 1);
592        assert!(jar.cookies_for_url(&http_url).is_empty());
593    }
594
595    #[test]
596    fn test_same_name_different_domain_coexist() {
597        use http::Uri;
598        let url_a: Uri = "http://a.example.com/".parse().expect("uri");
599        let url_b: Uri = "http://b.example.com/".parse().expect("uri");
600        let mut jar = CookieJar::new();
601        jar.insert_for_url(Cookie::new("token", "aaa"), &url_a);
602        jar.insert_for_url(Cookie::new("token", "bbb"), &url_b);
603        assert_eq!(jar.cookies.len(), 2);
604        assert_eq!(jar.cookies_for_url(&url_a)[0].value, "aaa");
605        assert_eq!(jar.cookies_for_url(&url_b)[0].value, "bbb");
606    }
607
608    // -------------------------------------------------------------------------
609    // Proptest: Cookie name/value round-trip stability
610    // -------------------------------------------------------------------------
611
612    use proptest::prelude::*;
613
614    /// Generate strings that are safe cookie name tokens:
615    /// non-empty, alphanumeric only (strict subset of RFC 7230 token chars).
616    fn cookie_name_strategy() -> impl Strategy<Value = String> {
617        proptest::string::string_regex("[a-zA-Z][a-zA-Z0-9]{0,31}").expect("valid regex")
618    }
619
620    /// Generate strings that are safe cookie values:
621    /// alphanumeric only, may be empty, no `;` or `=` or whitespace.
622    fn cookie_value_strategy() -> impl Strategy<Value = String> {
623        proptest::string::string_regex("[a-zA-Z0-9]{0,64}").expect("valid regex")
624    }
625
626    proptest! {
627        #[test]
628        fn prop_cookie_round_trip(
629            name in cookie_name_strategy(),
630            value in cookie_value_strategy(),
631        ) {
632            let original = Cookie::new(name.clone(), value.clone());
633            let serialized = original.to_string();
634            let parsed = Cookie::parse_set_cookie(&serialized)
635                .expect("round-trip parse must succeed for valid name/value");
636            prop_assert_eq!(&parsed.name, &name);
637            prop_assert_eq!(&parsed.value, &value);
638        }
639    }
640
641    // Known special-case round trips (no whitespace, no control chars)
642    #[test]
643    fn test_cookie_round_trip_known_cases() {
644        let cases = [
645            ("session", "abc123"),
646            ("LANG", "en"),
647            ("x", ""),
648            ("Token", "abcdefghijklmnopqrstuvwxyz"),
649            ("A1B2", "Z9Y8X7"),
650        ];
651        for (name, value) in cases {
652            let original = Cookie::new(name, value);
653            let serialized = original.to_string();
654            let parsed = Cookie::parse_set_cookie(&serialized)
655                .unwrap_or_else(|| panic!("failed to parse round-trip for {name}={value}"));
656            assert_eq!(parsed.name, name, "name mismatch for {name}");
657            assert_eq!(parsed.value, value, "value mismatch for {name}={value}");
658        }
659    }
660}