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    /// Set the domain
53    pub fn domain(mut self, domain: impl Into<String>) -> Self {
54        self.domain = Some(domain.into());
55        self
56    }
57
58    /// Set the path
59    pub fn path(mut self, path: impl Into<String>) -> Self {
60        self.path = Some(path.into());
61        self
62    }
63
64    /// Set max-age in seconds
65    pub fn max_age(mut self, seconds: i64) -> Self {
66        self.max_age = Some(seconds);
67        self
68    }
69
70    /// Mark as secure (HTTPS only)
71    pub fn secure(mut self) -> Self {
72        self.secure = true;
73        self
74    }
75
76    /// Mark as HttpOnly (not accessible via JavaScript)
77    pub fn http_only(mut self) -> Self {
78        self.http_only = true;
79        self
80    }
81
82    /// Set SameSite attribute
83    pub fn same_site(mut self, same_site: SameSite) -> Self {
84        // SameSite=None requires Secure flag per RFC 6265bis
85        if same_site == SameSite::None {
86            self.secure = true;
87        }
88        self.same_site = Some(same_site);
89        self
90    }
91
92    /// Get cookie name
93    pub fn name(&self) -> &str {
94        &self.name
95    }
96
97    /// Get cookie value
98    pub fn value(&self) -> &str {
99        &self.value
100    }
101
102    /// Convert to Set-Cookie header value
103    pub fn to_header_value(&self) -> String {
104        let mut parts = vec![format!("{}={}", self.name, self.value)];
105
106        if let Some(domain) = &self.domain {
107            parts.push(format!("Domain={}", domain));
108        }
109
110        if let Some(path) = &self.path {
111            parts.push(format!("Path={}", path));
112        }
113
114        if let Some(max_age) = self.max_age {
115            parts.push(format!("Max-Age={}", max_age));
116        }
117
118        if self.secure {
119            parts.push("Secure".to_string());
120        }
121
122        if self.http_only {
123            parts.push("HttpOnly".to_string());
124        }
125
126        if let Some(same_site) = self.same_site {
127            parts.push(format!("SameSite={}", same_site));
128        }
129
130        parts.join("; ")
131    }
132}
133
134impl fmt::Display for SameSite {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        match self {
137            SameSite::Strict => write!(f, "Strict"),
138            SameSite::Lax => write!(f, "Lax"),
139            SameSite::None => write!(f, "None"),
140        }
141    }
142}
143
144/// Collection of cookies from a request
145#[derive(Debug, Clone, Default)]
146pub struct Cookies {
147    cookies: HashMap<String, String>,
148}
149
150impl Cookies {
151    /// Create empty cookies collection
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Parse cookies from Cookie header value
157    pub fn parse(cookie_header: &str) -> Self {
158        let mut cookies = HashMap::new();
159
160        for pair in cookie_header.split(';') {
161            let pair = pair.trim();
162            if let Some((name, value)) = pair.split_once('=') {
163                cookies.insert(name.trim().to_string(), value.trim().to_string());
164            }
165        }
166
167        Self { cookies }
168    }
169
170    /// Get a cookie value by name
171    pub fn get(&self, name: &str) -> Option<&str> {
172        self.cookies.get(name).map(|s| s.as_str())
173    }
174
175    /// Check if a cookie exists
176    pub fn contains(&self, name: &str) -> bool {
177        self.cookies.contains_key(name)
178    }
179
180    /// Get all cookie names
181    pub fn names(&self) -> impl Iterator<Item = &str> {
182        self.cookies.keys().map(|s| s.as_str())
183    }
184
185    /// Get the number of cookies
186    pub fn len(&self) -> usize {
187        self.cookies.len()
188    }
189
190    /// Check if there are no cookies
191    pub fn is_empty(&self) -> bool {
192        self.cookies.is_empty()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_cookie_basic() {
202        let cookie = Cookie::new("session", "abc123");
203        assert_eq!(cookie.name(), "session");
204        assert_eq!(cookie.value(), "abc123");
205    }
206
207    #[test]
208    fn test_cookie_to_header() {
209        let cookie = Cookie::new("session", "abc123")
210            .path("/")
211            .max_age(3600)
212            .http_only()
213            .secure()
214            .same_site(SameSite::Strict);
215
216        let header = cookie.to_header_value();
217        assert!(header.contains("session=abc123"));
218        assert!(header.contains("Path=/"));
219        assert!(header.contains("Max-Age=3600"));
220        assert!(header.contains("HttpOnly"));
221        assert!(header.contains("Secure"));
222        assert!(header.contains("SameSite=Strict"));
223    }
224
225    #[test]
226    fn test_cookies_parse() {
227        let cookies = Cookies::parse("session=abc123; user=john; theme=dark");
228        assert_eq!(cookies.get("session"), Some("abc123"));
229        assert_eq!(cookies.get("user"), Some("john"));
230        assert_eq!(cookies.get("theme"), Some("dark"));
231        assert_eq!(cookies.len(), 3);
232    }
233
234    #[test]
235    fn test_cookies_parse_with_spaces() {
236        let cookies = Cookies::parse("session=abc123 ; user = john  ;  theme=dark");
237        assert_eq!(cookies.get("session"), Some("abc123"));
238        assert_eq!(cookies.get("user"), Some("john"));
239        assert_eq!(cookies.get("theme"), Some("dark"));
240    }
241
242    #[test]
243    fn test_cookies_empty() {
244        let cookies = Cookies::new();
245        assert!(cookies.is_empty());
246        assert_eq!(cookies.len(), 0);
247    }
248}