1use std::collections::HashMap;
6use std::time::Duration;
7
8#[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#[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#[derive(Clone, Debug)]
60pub struct Cookie {
61 name: String,
62 value: String,
63 options: CookieOptions,
64}
65
66impl Cookie {
67 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 pub fn name(&self) -> &str {
84 &self.name
85 }
86
87 pub fn value(&self) -> &str {
89 &self.value
90 }
91
92 pub fn http_only(mut self, value: bool) -> Self {
96 self.options.http_only = value;
97 self
98 }
99
100 pub fn secure(mut self, value: bool) -> Self {
104 self.options.secure = value;
105 self
106 }
107
108 pub fn same_site(mut self, value: SameSite) -> Self {
112 self.options.same_site = value;
113 self
114 }
115
116 pub fn max_age(mut self, duration: Duration) -> Self {
120 self.options.max_age = Some(duration);
121 self
122 }
123
124 pub fn path(mut self, path: impl Into<String>) -> Self {
126 self.options.path = path.into();
127 self
128 }
129
130 pub fn domain(mut self, domain: impl Into<String>) -> Self {
132 self.options.domain = Some(domain.into());
133 self
134 }
135
136 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 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 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
192pub 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
219fn 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
252fn 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}