1use std::collections::HashMap;
6use std::time::Duration;
7
8#[derive(Clone, Debug, Default, PartialEq)]
10pub enum SameSite {
11 Strict,
13 #[default]
15 Lax,
16 None,
18}
19
20#[derive(Clone, Debug)]
22pub struct CookieOptions {
23 pub http_only: bool,
25 pub secure: bool,
27 pub same_site: SameSite,
29 pub path: String,
31 pub domain: Option<String>,
33 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#[derive(Clone, Debug)]
64pub struct Cookie {
65 name: String,
66 value: String,
67 options: CookieOptions,
68}
69
70impl Cookie {
71 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 pub fn name(&self) -> &str {
88 &self.name
89 }
90
91 pub fn value(&self) -> &str {
93 &self.value
94 }
95
96 pub fn http_only(mut self, value: bool) -> Self {
100 self.options.http_only = value;
101 self
102 }
103
104 pub fn secure(mut self, value: bool) -> Self {
108 self.options.secure = value;
109 self
110 }
111
112 pub fn same_site(mut self, value: SameSite) -> Self {
116 self.options.same_site = value;
117 self
118 }
119
120 pub fn max_age(mut self, duration: Duration) -> Self {
124 self.options.max_age = Some(duration);
125 self
126 }
127
128 pub fn path(mut self, path: impl Into<String>) -> Self {
130 self.options.path = path.into();
131 self
132 }
133
134 pub fn domain(mut self, domain: impl Into<String>) -> Self {
136 self.options.domain = Some(domain.into());
137 self
138 }
139
140 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 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 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
196pub 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
220fn 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
253fn 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}