kit_rs/http/
cookie.rs

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