Skip to main content

ferro_rs/http/
cookie.rs

1//! Cookie handling for Ferro framework
2//!
3//! Provides Laravel-like cookie API with secure defaults.
4
5use std::collections::HashMap;
6use std::time::Duration;
7
8/// SameSite cookie attribute
9#[derive(Clone, Debug, Default, PartialEq)]
10pub enum SameSite {
11    Strict,
12    #[default]
13    Lax,
14    None,
15}
16
17/// Cookie options with secure defaults
18#[derive(Clone, Debug)]
19pub struct CookieOptions {
20    pub http_only: bool,
21    pub secure: bool,
22    pub same_site: SameSite,
23    pub path: String,
24    pub domain: Option<String>,
25    pub max_age: Option<Duration>,
26}
27
28impl Default for CookieOptions {
29    fn default() -> Self {
30        Self {
31            http_only: true,
32            secure: true,
33            same_site: SameSite::Lax,
34            path: "/".to_string(),
35            domain: None,
36            max_age: None,
37        }
38    }
39}
40
41/// Cookie builder with fluent API
42///
43/// # Example
44///
45/// ```rust,ignore
46/// use ferro_rs::Cookie;
47/// use std::time::Duration;
48///
49/// let cookie = Cookie::new("session", "abc123")
50///     .http_only(true)
51///     .secure(true)
52///     .max_age(Duration::from_secs(3600));
53/// ```
54#[derive(Clone, Debug)]
55pub struct Cookie {
56    name: String,
57    value: String,
58    options: CookieOptions,
59}
60
61impl Cookie {
62    /// Create a new cookie with the given name and value
63    ///
64    /// Default options:
65    /// - HttpOnly: true
66    /// - Secure: true
67    /// - SameSite: Lax
68    /// - Path: "/"
69    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
70        Self {
71            name: name.into(),
72            value: value.into(),
73            options: CookieOptions::default(),
74        }
75    }
76
77    /// Get the cookie name
78    pub fn name(&self) -> &str {
79        &self.name
80    }
81
82    /// Get the cookie value
83    pub fn value(&self) -> &str {
84        &self.value
85    }
86
87    /// Set the HttpOnly flag (default: true)
88    ///
89    /// HttpOnly cookies are not accessible via JavaScript, protecting against XSS.
90    pub fn http_only(mut self, value: bool) -> Self {
91        self.options.http_only = value;
92        self
93    }
94
95    /// Set the Secure flag (default: true)
96    ///
97    /// Secure cookies are only sent over HTTPS connections.
98    pub fn secure(mut self, value: bool) -> Self {
99        self.options.secure = value;
100        self
101    }
102
103    /// Set the SameSite attribute (default: Lax)
104    ///
105    /// Controls when the cookie is sent with cross-site requests.
106    pub fn same_site(mut self, value: SameSite) -> Self {
107        self.options.same_site = value;
108        self
109    }
110
111    /// Set the cookie's max age
112    ///
113    /// The cookie will expire after this duration.
114    pub fn max_age(mut self, duration: Duration) -> Self {
115        self.options.max_age = Some(duration);
116        self
117    }
118
119    /// Set the cookie path (default: "/")
120    pub fn path(mut self, path: impl Into<String>) -> Self {
121        self.options.path = path.into();
122        self
123    }
124
125    /// Set the cookie domain
126    pub fn domain(mut self, domain: impl Into<String>) -> Self {
127        self.options.domain = Some(domain.into());
128        self
129    }
130
131    /// Build the Set-Cookie header value
132    pub fn to_header_value(&self) -> String {
133        let mut parts = vec![format!(
134            "{}={}",
135            url_encode(&self.name),
136            url_encode(&self.value)
137        )];
138
139        parts.push(format!("Path={}", self.options.path));
140
141        if self.options.http_only {
142            parts.push("HttpOnly".to_string());
143        }
144
145        if self.options.secure {
146            parts.push("Secure".to_string());
147        }
148
149        match self.options.same_site {
150            SameSite::Strict => parts.push("SameSite=Strict".to_string()),
151            SameSite::Lax => parts.push("SameSite=Lax".to_string()),
152            SameSite::None => parts.push("SameSite=None".to_string()),
153        }
154
155        if let Some(ref domain) = self.options.domain {
156            parts.push(format!("Domain={domain}"));
157        }
158
159        if let Some(max_age) = self.options.max_age {
160            parts.push(format!("Max-Age={}", max_age.as_secs()));
161        }
162
163        parts.join("; ")
164    }
165
166    /// Create a cookie that deletes itself (for logout)
167    ///
168    /// # Example
169    ///
170    /// ```rust,ignore
171    /// let forget = Cookie::forget("session");
172    /// response.cookie(forget)
173    /// ```
174    pub fn forget(name: impl Into<String>) -> Self {
175        Self::new(name, "")
176            .max_age(Duration::from_secs(0))
177            .http_only(true)
178            .secure(true)
179    }
180
181    /// Create a permanent cookie (5 years)
182    pub fn forever(name: impl Into<String>, value: impl Into<String>) -> Self {
183        Self::new(name, value).max_age(Duration::from_secs(5 * 365 * 24 * 60 * 60))
184    }
185}
186
187/// Parse cookies from a Cookie header value
188///
189/// # Example
190///
191/// ```rust,ignore
192/// let cookies = parse_cookies("session=abc123; user_id=42");
193/// assert_eq!(cookies.get("session"), Some(&"abc123".to_string()));
194/// ```
195pub fn parse_cookies(header: &str) -> HashMap<String, String> {
196    header
197        .split(';')
198        .filter_map(|part| {
199            let part = part.trim();
200            if part.is_empty() {
201                return None;
202            }
203            let mut parts = part.splitn(2, '=');
204            let name = parts.next()?.trim();
205            let value = parts.next().unwrap_or("").trim();
206            Some((url_decode(name), url_decode(value)))
207        })
208        .collect()
209}
210
211/// Simple URL encoding for cookie values
212fn url_encode(s: &str) -> String {
213    let mut result = String::with_capacity(s.len());
214    for c in s.chars() {
215        match c {
216            ' ' => result.push_str("%20"),
217            '!' => result.push_str("%21"),
218            '"' => result.push_str("%22"),
219            '#' => result.push_str("%23"),
220            '$' => result.push_str("%24"),
221            '%' => result.push_str("%25"),
222            '&' => result.push_str("%26"),
223            '\'' => result.push_str("%27"),
224            '(' => result.push_str("%28"),
225            ')' => result.push_str("%29"),
226            '*' => result.push_str("%2A"),
227            '+' => result.push_str("%2B"),
228            ',' => result.push_str("%2C"),
229            '/' => result.push_str("%2F"),
230            ':' => result.push_str("%3A"),
231            ';' => result.push_str("%3B"),
232            '=' => result.push_str("%3D"),
233            '?' => result.push_str("%3F"),
234            '@' => result.push_str("%40"),
235            '[' => result.push_str("%5B"),
236            '\\' => result.push_str("%5C"),
237            ']' => result.push_str("%5D"),
238            _ => result.push(c),
239        }
240    }
241    result
242}
243
244/// Simple URL decoding for cookie values
245fn url_decode(s: &str) -> String {
246    let mut result = String::with_capacity(s.len());
247    let mut chars = s.chars().peekable();
248
249    while let Some(c) = chars.next() {
250        if c == '%' {
251            let hex: String = chars.by_ref().take(2).collect();
252            if hex.len() == 2 {
253                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
254                    result.push(byte as char);
255                    continue;
256                }
257            }
258            result.push('%');
259            result.push_str(&hex);
260        } else if c == '+' {
261            result.push(' ');
262        } else {
263            result.push(c);
264        }
265    }
266    result
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_cookie_builder() {
275        let cookie = Cookie::new("test", "value")
276            .http_only(true)
277            .secure(true)
278            .same_site(SameSite::Strict)
279            .path("/app")
280            .max_age(Duration::from_secs(3600));
281
282        let header = cookie.to_header_value();
283        assert!(header.contains("test=value"));
284        assert!(header.contains("HttpOnly"));
285        assert!(header.contains("Secure"));
286        assert!(header.contains("SameSite=Strict"));
287        assert!(header.contains("Path=/app"));
288        assert!(header.contains("Max-Age=3600"));
289    }
290
291    #[test]
292    fn test_parse_cookies() {
293        let cookies = parse_cookies("session=abc123; user_id=42; empty=");
294        assert_eq!(cookies.get("session"), Some(&"abc123".to_string()));
295        assert_eq!(cookies.get("user_id"), Some(&"42".to_string()));
296        assert_eq!(cookies.get("empty"), Some(&"".to_string()));
297    }
298
299    #[test]
300    fn test_forget_cookie() {
301        let cookie = Cookie::forget("session");
302        let header = cookie.to_header_value();
303        assert!(header.contains("Max-Age=0"));
304        assert!(header.contains("session="));
305    }
306}