Skip to main content

ullun/
cookies.rs

1//! Cookie parsing and management
2
3use std::collections::HashMap;
4use std::fmt;
5
6/// A single HTTP cookie
7#[derive(Debug, Clone)]
8pub struct Cookie {
9    name: String,
10    value: String,
11    domain: Option<String>,
12    path: Option<String>,
13    max_age: Option<i64>,
14    secure: bool,
15    http_only: bool,
16    same_site: Option<SameSite>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SameSite {
21    Strict,
22    Lax,
23    None,
24}
25
26impl Cookie {
27    /// Create a new cookie
28    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
29        let name = name.into();
30        let value = value.into();
31
32        // Validate cookie name and value to prevent injection
33        if name.contains(|c: char| c == ';' || c == '=' || c.is_control()) {
34            panic!("Invalid cookie name: contains forbidden characters");
35        }
36        if value.contains(|c: char| c == ';' || c.is_control()) {
37            panic!("Invalid cookie value: contains forbidden characters");
38        }
39
40        Self {
41            name,
42            value,
43            domain: None,
44            path: Some("/".to_string()),
45            max_age: None,
46            secure: false,
47            http_only: false,
48            same_site: None,
49        }
50    }
51
52    /// Replace the value (used by signing and encryption); same character rules as [`Cookie::new`]
53    #[cfg(any(feature = "signing", feature = "encryption"))]
54    pub(crate) fn with_value(mut self, value: String) -> Self {
55        debug_assert!(!value.contains(|c: char| c == ';' || c.is_control()));
56        self.value = value;
57        self
58    }
59
60    /// Set the domain
61    pub fn domain(mut self, domain: impl Into<String>) -> Self {
62        self.domain = Some(domain.into());
63        self
64    }
65
66    /// Set the path
67    pub fn path(mut self, path: impl Into<String>) -> Self {
68        self.path = Some(path.into());
69        self
70    }
71
72    /// Set max-age in seconds
73    pub fn max_age(mut self, seconds: i64) -> Self {
74        self.max_age = Some(seconds);
75        self
76    }
77
78    /// Mark as secure (HTTPS only)
79    pub fn secure(mut self) -> Self {
80        self.secure = true;
81        self
82    }
83
84    /// Mark as HttpOnly (not accessible via JavaScript)
85    pub fn http_only(mut self) -> Self {
86        self.http_only = true;
87        self
88    }
89
90    /// Set SameSite attribute
91    pub fn same_site(mut self, same_site: SameSite) -> Self {
92        // SameSite=None requires Secure flag per RFC 6265bis
93        if same_site == SameSite::None {
94            self.secure = true;
95        }
96        self.same_site = Some(same_site);
97        self
98    }
99
100    /// Get cookie name
101    pub fn name(&self) -> &str {
102        &self.name
103    }
104
105    /// Get cookie value
106    pub fn value(&self) -> &str {
107        &self.value
108    }
109
110    /// Convert to Set-Cookie header value
111    pub fn to_header_value(&self) -> String {
112        let mut parts = vec![format!("{}={}", self.name, self.value)];
113
114        if let Some(domain) = &self.domain {
115            parts.push(format!("Domain={}", domain));
116        }
117
118        if let Some(path) = &self.path {
119            parts.push(format!("Path={}", path));
120        }
121
122        if let Some(max_age) = self.max_age {
123            parts.push(format!("Max-Age={}", max_age));
124        }
125
126        if self.secure {
127            parts.push("Secure".to_string());
128        }
129
130        if self.http_only {
131            parts.push("HttpOnly".to_string());
132        }
133
134        if let Some(same_site) = self.same_site {
135            parts.push(format!("SameSite={}", same_site));
136        }
137
138        parts.join("; ")
139    }
140}
141
142impl fmt::Display for SameSite {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            SameSite::Strict => write!(f, "Strict"),
146            SameSite::Lax => write!(f, "Lax"),
147            SameSite::None => write!(f, "None"),
148        }
149    }
150}
151
152/// Collection of cookies from a request
153#[derive(Debug, Clone, Default)]
154pub struct Cookies {
155    cookies: HashMap<String, String>,
156}
157
158impl Cookies {
159    /// Create empty cookies collection
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Parse cookies from Cookie header value
165    pub fn parse(cookie_header: &str) -> Self {
166        let mut cookies = HashMap::new();
167
168        for pair in cookie_header.split(';') {
169            let pair = pair.trim();
170            if let Some((name, value)) = pair.split_once('=') {
171                cookies.insert(name.trim().to_string(), value.trim().to_string());
172            }
173        }
174
175        Self { cookies }
176    }
177
178    /// Get a cookie value by name
179    pub fn get(&self, name: &str) -> Option<&str> {
180        self.cookies.get(name).map(|s| s.as_str())
181    }
182
183    /// Check if a cookie exists
184    pub fn contains(&self, name: &str) -> bool {
185        self.cookies.contains_key(name)
186    }
187
188    /// Get all cookie names
189    pub fn names(&self) -> impl Iterator<Item = &str> {
190        self.cookies.keys().map(|s| s.as_str())
191    }
192
193    /// Get the number of cookies
194    pub fn len(&self) -> usize {
195        self.cookies.len()
196    }
197
198    /// Check if there are no cookies
199    pub fn is_empty(&self) -> bool {
200        self.cookies.is_empty()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_cookie_basic() {
210        let cookie = Cookie::new("session", "abc123");
211        assert_eq!(cookie.name(), "session");
212        assert_eq!(cookie.value(), "abc123");
213    }
214
215    #[test]
216    fn test_cookie_to_header() {
217        let cookie = Cookie::new("session", "abc123")
218            .path("/")
219            .max_age(3600)
220            .http_only()
221            .secure()
222            .same_site(SameSite::Strict);
223
224        let header = cookie.to_header_value();
225        assert!(header.contains("session=abc123"));
226        assert!(header.contains("Path=/"));
227        assert!(header.contains("Max-Age=3600"));
228        assert!(header.contains("HttpOnly"));
229        assert!(header.contains("Secure"));
230        assert!(header.contains("SameSite=Strict"));
231    }
232
233    #[test]
234    fn test_cookies_parse() {
235        let cookies = Cookies::parse("session=abc123; user=john; theme=dark");
236        assert_eq!(cookies.get("session"), Some("abc123"));
237        assert_eq!(cookies.get("user"), Some("john"));
238        assert_eq!(cookies.get("theme"), Some("dark"));
239        assert_eq!(cookies.len(), 3);
240    }
241
242    #[test]
243    fn test_cookies_parse_with_spaces() {
244        let cookies = Cookies::parse("session=abc123 ; user = john  ;  theme=dark");
245        assert_eq!(cookies.get("session"), Some("abc123"));
246        assert_eq!(cookies.get("user"), Some("john"));
247        assert_eq!(cookies.get("theme"), Some("dark"));
248    }
249
250    #[test]
251    fn test_cookies_empty() {
252        let cookies = Cookies::new();
253        assert!(cookies.is_empty());
254        assert_eq!(cookies.len(), 0);
255    }
256}