1use std::collections::HashMap;
4use std::fmt;
5
6#[derive(Debug, Clone)]
8pub struct Cookie {
9 name: String,
10 value: String,
11 domain: Option<String>,
12 path: Option<String>,
13 max_age: Option<i64>,
14 secure: bool,
15 http_only: bool,
16 same_site: Option<SameSite>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SameSite {
21 Strict,
22 Lax,
23 None,
24}
25
26impl Cookie {
27 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
29 let name = name.into();
30 let value = value.into();
31
32 if name.contains(|c: char| c == ';' || c == '=' || c.is_control()) {
34 panic!("Invalid cookie name: contains forbidden characters");
35 }
36 if value.contains(|c: char| c == ';' || c.is_control()) {
37 panic!("Invalid cookie value: contains forbidden characters");
38 }
39
40 Self {
41 name,
42 value,
43 domain: None,
44 path: Some("/".to_string()),
45 max_age: None,
46 secure: false,
47 http_only: false,
48 same_site: None,
49 }
50 }
51
52 #[cfg(any(feature = "signing", feature = "encryption"))]
54 pub(crate) fn with_value(mut self, value: String) -> Self {
55 debug_assert!(!value.contains(|c: char| c == ';' || c.is_control()));
56 self.value = value;
57 self
58 }
59
60 pub fn domain(mut self, domain: impl Into<String>) -> Self {
62 self.domain = Some(domain.into());
63 self
64 }
65
66 pub fn path(mut self, path: impl Into<String>) -> Self {
68 self.path = Some(path.into());
69 self
70 }
71
72 pub fn max_age(mut self, seconds: i64) -> Self {
74 self.max_age = Some(seconds);
75 self
76 }
77
78 pub fn secure(mut self) -> Self {
80 self.secure = true;
81 self
82 }
83
84 pub fn http_only(mut self) -> Self {
86 self.http_only = true;
87 self
88 }
89
90 pub fn same_site(mut self, same_site: SameSite) -> Self {
92 if same_site == SameSite::None {
94 self.secure = true;
95 }
96 self.same_site = Some(same_site);
97 self
98 }
99
100 pub fn name(&self) -> &str {
102 &self.name
103 }
104
105 pub fn value(&self) -> &str {
107 &self.value
108 }
109
110 pub fn to_header_value(&self) -> String {
112 let mut parts = vec![format!("{}={}", self.name, self.value)];
113
114 if let Some(domain) = &self.domain {
115 parts.push(format!("Domain={}", domain));
116 }
117
118 if let Some(path) = &self.path {
119 parts.push(format!("Path={}", path));
120 }
121
122 if let Some(max_age) = self.max_age {
123 parts.push(format!("Max-Age={}", max_age));
124 }
125
126 if self.secure {
127 parts.push("Secure".to_string());
128 }
129
130 if self.http_only {
131 parts.push("HttpOnly".to_string());
132 }
133
134 if let Some(same_site) = self.same_site {
135 parts.push(format!("SameSite={}", same_site));
136 }
137
138 parts.join("; ")
139 }
140}
141
142impl fmt::Display for SameSite {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 SameSite::Strict => write!(f, "Strict"),
146 SameSite::Lax => write!(f, "Lax"),
147 SameSite::None => write!(f, "None"),
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct Cookies {
155 cookies: HashMap<String, String>,
156}
157
158impl Cookies {
159 pub fn new() -> Self {
161 Self::default()
162 }
163
164 pub fn parse(cookie_header: &str) -> Self {
166 let mut cookies = HashMap::new();
167
168 for pair in cookie_header.split(';') {
169 let pair = pair.trim();
170 if let Some((name, value)) = pair.split_once('=') {
171 cookies.insert(name.trim().to_string(), value.trim().to_string());
172 }
173 }
174
175 Self { cookies }
176 }
177
178 pub fn get(&self, name: &str) -> Option<&str> {
180 self.cookies.get(name).map(|s| s.as_str())
181 }
182
183 pub fn contains(&self, name: &str) -> bool {
185 self.cookies.contains_key(name)
186 }
187
188 pub fn names(&self) -> impl Iterator<Item = &str> {
190 self.cookies.keys().map(|s| s.as_str())
191 }
192
193 pub fn len(&self) -> usize {
195 self.cookies.len()
196 }
197
198 pub fn is_empty(&self) -> bool {
200 self.cookies.is_empty()
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_cookie_basic() {
210 let cookie = Cookie::new("session", "abc123");
211 assert_eq!(cookie.name(), "session");
212 assert_eq!(cookie.value(), "abc123");
213 }
214
215 #[test]
216 fn test_cookie_to_header() {
217 let cookie = Cookie::new("session", "abc123")
218 .path("/")
219 .max_age(3600)
220 .http_only()
221 .secure()
222 .same_site(SameSite::Strict);
223
224 let header = cookie.to_header_value();
225 assert!(header.contains("session=abc123"));
226 assert!(header.contains("Path=/"));
227 assert!(header.contains("Max-Age=3600"));
228 assert!(header.contains("HttpOnly"));
229 assert!(header.contains("Secure"));
230 assert!(header.contains("SameSite=Strict"));
231 }
232
233 #[test]
234 fn test_cookies_parse() {
235 let cookies = Cookies::parse("session=abc123; user=john; theme=dark");
236 assert_eq!(cookies.get("session"), Some("abc123"));
237 assert_eq!(cookies.get("user"), Some("john"));
238 assert_eq!(cookies.get("theme"), Some("dark"));
239 assert_eq!(cookies.len(), 3);
240 }
241
242 #[test]
243 fn test_cookies_parse_with_spaces() {
244 let cookies = Cookies::parse("session=abc123 ; user = john ; theme=dark");
245 assert_eq!(cookies.get("session"), Some("abc123"));
246 assert_eq!(cookies.get("user"), Some("john"));
247 assert_eq!(cookies.get("theme"), Some("dark"));
248 }
249
250 #[test]
251 fn test_cookies_empty() {
252 let cookies = Cookies::new();
253 assert!(cookies.is_empty());
254 assert_eq!(cookies.len(), 0);
255 }
256}