Skip to main content

rustlavel_http/
cookie.rs

1//! Cookie construction and parsing.
2
3use crate::url;
4use std::collections::BTreeMap;
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SameSite {
9    Strict,
10    Lax,
11    None,
12}
13
14impl SameSite {
15    fn as_str(self) -> &'static str {
16        match self {
17            SameSite::Strict => "Strict",
18            SameSite::Lax => "Lax",
19            SameSite::None => "None",
20        }
21    }
22}
23
24/// A cookie to be sent with a response.
25///
26/// Defaults are the safe ones — `HttpOnly`, `SameSite=Lax`, path `/` — so a
27/// session cookie is hardened unless the application opts out.
28#[derive(Debug, Clone)]
29pub struct Cookie {
30    pub name: String,
31    pub value: String,
32    pub path: Option<String>,
33    pub domain: Option<String>,
34    pub max_age: Option<Duration>,
35    pub secure: bool,
36    pub http_only: bool,
37    pub same_site: Option<SameSite>,
38    /// Set independently of `max_age` to expire a cookie in the past.
39    expires_unix: Option<i64>,
40}
41
42impl Cookie {
43    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
44        Cookie {
45            name: name.into(),
46            value: value.into(),
47            path: Some("/".into()),
48            domain: None,
49            max_age: None,
50            secure: false,
51            http_only: true,
52            same_site: Some(SameSite::Lax),
53            expires_unix: None,
54        }
55    }
56
57    /// A cookie that instructs the browser to drop an existing one.
58    pub fn forget(name: impl Into<String>) -> Self {
59        let mut cookie = Cookie::new(name, "");
60        cookie.max_age = Some(Duration::ZERO);
61        cookie.expires_unix = Some(0);
62        cookie
63    }
64
65    pub fn path(mut self, path: impl Into<String>) -> Self {
66        self.path = Some(path.into());
67        self
68    }
69
70    pub fn domain(mut self, domain: impl Into<String>) -> Self {
71        self.domain = Some(domain.into());
72        self
73    }
74
75    pub fn max_age(mut self, age: Duration) -> Self {
76        self.max_age = Some(age);
77        self
78    }
79
80    pub fn secure(mut self, secure: bool) -> Self {
81        self.secure = secure;
82        self
83    }
84
85    pub fn http_only(mut self, http_only: bool) -> Self {
86        self.http_only = http_only;
87        self
88    }
89
90    pub fn same_site(mut self, same_site: SameSite) -> Self {
91        self.same_site = Some(same_site);
92        self
93    }
94
95    /// Render the `Set-Cookie` header value.
96    pub fn to_header(&self) -> String {
97        let mut out = format!("{}={}", self.name, url::encode(&self.value));
98
99        if let Some(path) = &self.path {
100            out.push_str("; Path=");
101            out.push_str(path);
102        }
103        if let Some(domain) = &self.domain {
104            out.push_str("; Domain=");
105            out.push_str(domain);
106        }
107        if let Some(age) = self.max_age {
108            out.push_str(&format!("; Max-Age={}", age.as_secs()));
109        }
110        if let Some(expires) = self.expires_unix {
111            out.push_str(&format!("; Expires={}", crate::date::http_date(expires)));
112        }
113        if self.secure {
114            out.push_str("; Secure");
115        }
116        if self.http_only {
117            out.push_str("; HttpOnly");
118        }
119        if let Some(same_site) = self.same_site {
120            out.push_str("; SameSite=");
121            out.push_str(same_site.as_str());
122            // SameSite=None is only honoured on a Secure cookie.
123            if same_site == SameSite::None && !self.secure {
124                out.push_str("; Secure");
125            }
126        }
127        out
128    }
129}
130
131/// Parse a request's `Cookie` header into name/value pairs.
132pub fn parse_header(header: &str) -> BTreeMap<String, String> {
133    header
134        .split(';')
135        .filter_map(|pair| pair.trim().split_once('='))
136        .map(|(name, value)| (name.trim().to_string(), url::decode(value.trim())))
137        .collect()
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn defaults_are_the_hardened_ones() {
146        let header = Cookie::new("session", "abc").to_header();
147
148        assert!(header.starts_with("session=abc"));
149        assert!(header.contains("; Path=/"));
150        assert!(header.contains("; HttpOnly"));
151        assert!(header.contains("; SameSite=Lax"));
152    }
153
154    #[test]
155    fn same_site_none_forces_secure() {
156        let header = Cookie::new("x", "1").same_site(SameSite::None).to_header();
157        assert!(header.contains("; Secure"));
158    }
159
160    #[test]
161    fn values_are_encoded_and_decoded() {
162        let header = Cookie::new("greeting", "hello world").to_header();
163        assert!(header.starts_with("greeting=hello%20world"));
164
165        let parsed = parse_header("greeting=hello%20world; other=2");
166        assert_eq!(parsed["greeting"], "hello world");
167        assert_eq!(parsed["other"], "2");
168    }
169
170    #[test]
171    fn forget_expires_in_the_past() {
172        let header = Cookie::forget("session").to_header();
173        assert!(header.contains("Max-Age=0"));
174        assert!(header.contains("Expires=Thu, 01 Jan 1970 00:00:00 GMT"));
175    }
176
177}