1use std::collections::HashMap;
6use std::time::Duration;
7
8#[derive(Clone, Debug, Default, PartialEq)]
10pub enum SameSite {
11 Strict,
12 #[default]
13 Lax,
14 None,
15}
16
17#[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#[derive(Clone, Debug)]
55pub struct Cookie {
56 name: String,
57 value: String,
58 options: CookieOptions,
59}
60
61impl Cookie {
62 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 pub fn name(&self) -> &str {
79 &self.name
80 }
81
82 pub fn value(&self) -> &str {
84 &self.value
85 }
86
87 pub fn http_only(mut self, value: bool) -> Self {
91 self.options.http_only = value;
92 self
93 }
94
95 pub fn secure(mut self, value: bool) -> Self {
99 self.options.secure = value;
100 self
101 }
102
103 pub fn same_site(mut self, value: SameSite) -> Self {
107 self.options.same_site = value;
108 self
109 }
110
111 pub fn max_age(mut self, duration: Duration) -> Self {
115 self.options.max_age = Some(duration);
116 self
117 }
118
119 pub fn path(mut self, path: impl Into<String>) -> Self {
121 self.options.path = path.into();
122 self
123 }
124
125 pub fn domain(mut self, domain: impl Into<String>) -> Self {
127 self.options.domain = Some(domain.into());
128 self
129 }
130
131 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 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 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
187pub 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
211fn 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
244fn 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}