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