1use std::collections::HashMap;
5use std::fmt::Write as _;
6
7use chrono::{DateTime, Utc};
8use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
9
10const COOKIE_VALUE: &AsciiSet =
17 &CONTROLS.add(b' ').add(b'"').add(b',').add(b';').add(b'\\').add(b'%');
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SameSite {
22 #[default]
25 Lax,
26 Strict,
28 None,
31}
32
33impl SameSite {
34 fn as_str(self) -> &'static str {
35 match self {
36 SameSite::Lax => "Lax",
37 SameSite::Strict => "Strict",
38 SameSite::None => "None",
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct Cookie {
46 name: String,
47 value: String,
48 path: Option<String>,
49 domain: Option<String>,
50 max_age: Option<i64>,
51 expires: Option<DateTime<Utc>>,
52 secure: bool,
53 http_only: bool,
54 same_site: Option<SameSite>,
55}
56
57impl Cookie {
58 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
65 Self {
66 name: name.into(),
67 value: value.into(),
68 path: Some("/".to_string()),
69 domain: None,
70 max_age: None,
71 expires: None,
72 secure: false,
73 http_only: true,
74 same_site: Some(SameSite::Lax),
75 }
76 }
77
78 pub fn removal(name: impl Into<String>) -> Self {
80 Self::new(name, "").max_age(0).expires(DateTime::UNIX_EPOCH)
81 }
82
83 pub fn name(&self) -> &str {
85 &self.name
86 }
87
88 pub fn value(&self) -> &str {
90 &self.value
91 }
92
93 pub fn path(mut self, path: impl Into<String>) -> Self {
95 self.path = Some(path.into());
96 self
97 }
98
99 pub fn domain(mut self, domain: impl Into<String>) -> Self {
101 self.domain = Some(domain.into());
102 self
103 }
104
105 pub fn max_age(mut self, seconds: i64) -> Self {
107 self.max_age = Some(seconds);
108 self
109 }
110
111 pub fn expires(mut self, at: DateTime<Utc>) -> Self {
113 self.expires = Some(at);
114 self
115 }
116
117 pub fn secure(mut self, secure: bool) -> Self {
119 self.secure = secure;
120 self
121 }
122
123 pub fn http_only(mut self, http_only: bool) -> Self {
125 self.http_only = http_only;
126 self
127 }
128
129 pub fn same_site(mut self, same_site: SameSite) -> Self {
135 if same_site == SameSite::None {
136 self.secure = true;
137 }
138 self.same_site = Some(same_site);
139 self
140 }
141
142 pub fn to_set_cookie(&self) -> String {
144 let mut out = String::with_capacity(self.name.len() + self.value.len() + 48);
145 let _ = write!(
146 out,
147 "{}={}",
148 utf8_percent_encode(&self.name, COOKIE_VALUE),
149 utf8_percent_encode(&self.value, COOKIE_VALUE)
150 );
151
152 if let Some(path) = &self.path {
153 let _ = write!(out, "; Path={path}");
154 }
155 if let Some(domain) = &self.domain {
156 let _ = write!(out, "; Domain={domain}");
157 }
158 if let Some(max_age) = self.max_age {
159 let _ = write!(out, "; Max-Age={max_age}");
160 }
161 if let Some(expires) = self.expires {
162 let _ = write!(out, "; Expires={}", expires.format("%a, %d %b %Y %H:%M:%S GMT"));
164 }
165 if self.secure {
166 out.push_str("; Secure");
167 }
168 if self.http_only {
169 out.push_str("; HttpOnly");
170 }
171 if let Some(same_site) = self.same_site {
172 let _ = write!(out, "; SameSite={}", same_site.as_str());
173 }
174 out
175 }
176}
177
178pub fn parse_cookie_header(header: &str) -> HashMap<String, String> {
184 let mut cookies = HashMap::new();
185 for pair in header.split(';') {
186 let pair = pair.trim();
187 if pair.is_empty() {
188 continue;
189 }
190 let (name, value) = match pair.split_once('=') {
191 Some((name, value)) => (name.trim(), value.trim()),
192 None => (pair, ""),
195 };
196 cookies.insert(decode(name), decode(value));
197 }
198 cookies
199}
200
201fn decode(raw: &str) -> String {
202 let trimmed = raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(raw);
204 percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn defaults_are_the_safe_ones() {
213 let rendered = Cookie::new("session", "abc").to_set_cookie();
214 assert!(rendered.starts_with("session=abc"));
215 assert!(rendered.contains("; Path=/"));
216 assert!(rendered.contains("; HttpOnly"));
217 assert!(rendered.contains("; SameSite=Lax"));
218 assert!(!rendered.contains("Secure"));
219 }
220
221 #[test]
222 fn attributes_render() {
223 let rendered = Cookie::new("a", "b")
224 .path("/admin")
225 .domain("example.com")
226 .max_age(3600)
227 .secure(true)
228 .http_only(false)
229 .same_site(SameSite::Strict)
230 .to_set_cookie();
231
232 assert!(rendered.contains("; Path=/admin"));
233 assert!(rendered.contains("; Domain=example.com"));
234 assert!(rendered.contains("; Max-Age=3600"));
235 assert!(rendered.contains("; Secure"));
236 assert!(!rendered.contains("HttpOnly"));
237 assert!(rendered.contains("; SameSite=Strict"));
238 }
239
240 #[test]
241 fn same_site_none_forces_secure() {
242 let rendered = Cookie::new("a", "b").same_site(SameSite::None).to_set_cookie();
243 assert!(rendered.contains("; SameSite=None"));
244 assert!(rendered.contains("; Secure"), "browsers drop SameSite=None without Secure");
245 }
246
247 #[test]
248 fn removal_expires_in_the_past() {
249 let rendered = Cookie::removal("session").to_set_cookie();
250 assert!(rendered.starts_with("session="));
251 assert!(rendered.contains("; Max-Age=0"));
252 assert!(rendered.contains("Expires=Thu, 01 Jan 1970 00:00:00 GMT"));
253 }
254
255 #[test]
256 fn values_needing_escapes_are_encoded() {
257 let rendered = Cookie::new("data", "a;b c").to_set_cookie();
258 assert!(rendered.starts_with("data=a%3Bb%20c"), "{rendered}");
259 }
260
261 #[test]
262 fn parses_a_request_cookie_header() {
263 let cookies = parse_cookie_header("session=abc; theme=dark; ");
264 assert_eq!(cookies.get("session").map(String::as_str), Some("abc"));
265 assert_eq!(cookies.get("theme").map(String::as_str), Some("dark"));
266 assert_eq!(cookies.len(), 2);
267 }
268
269 #[test]
270 fn parsing_reverses_the_encoding() {
271 let rendered = Cookie::new("data", "a;b c").to_set_cookie();
272 let pair = rendered.split(';').next().unwrap();
273 assert_eq!(parse_cookie_header(pair).get("data").map(String::as_str), Some("a;b c"));
274 }
275
276 #[test]
277 fn handles_quoted_and_valueless_cookies() {
278 let cookies = parse_cookie_header("quoted=\"hello\"; bare");
279 assert_eq!(cookies.get("quoted").map(String::as_str), Some("hello"));
280 assert_eq!(cookies.get("bare").map(String::as_str), Some(""));
281 }
282
283 #[test]
284 fn an_empty_header_yields_nothing() {
285 assert!(parse_cookie_header("").is_empty());
286 assert!(parse_cookie_header(" ; ").is_empty());
287 }
288}