1use std::fmt::Write as _;
6
7use crate::error::ConfigError;
8
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
11pub enum SameSite {
12 Strict,
14 #[default]
16 Lax,
17 None,
19}
20
21impl SameSite {
22 const fn as_str(self) -> &'static str {
23 match self {
24 SameSite::Strict => "Strict",
25 SameSite::Lax => "Lax",
26 SameSite::None => "None",
27 }
28 }
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct CookieConfig {
44 name: String,
45 path: String,
46 domain: Option<String>,
47 secure: bool,
48 http_only: bool,
49 same_site: SameSite,
50 persistent: bool,
51}
52
53impl Default for CookieConfig {
54 fn default() -> Self {
55 Self {
56 name: "__Host-sid".to_owned(),
57 path: "/".to_owned(),
58 domain: None,
59 secure: true,
60 http_only: true,
61 same_site: SameSite::Lax,
62 persistent: true,
63 }
64 }
65}
66
67impl CookieConfig {
68 pub fn named(name: impl Into<String>) -> Self {
70 Self::default().name(name)
71 }
72
73 #[must_use]
75 pub fn name(mut self, name: impl Into<String>) -> Self {
76 self.name = name.into();
77 self
78 }
79
80 #[must_use]
82 pub fn path(mut self, path: impl Into<String>) -> Self {
83 self.path = path.into();
84 self
85 }
86
87 #[must_use]
89 pub fn domain(mut self, domain: impl Into<String>) -> Self {
90 self.domain = Some(domain.into());
91 self
92 }
93
94 #[must_use]
97 pub const fn secure(mut self, secure: bool) -> Self {
98 self.secure = secure;
99 self
100 }
101
102 #[must_use]
104 pub const fn http_only(mut self, http_only: bool) -> Self {
105 self.http_only = http_only;
106 self
107 }
108
109 #[must_use]
111 pub const fn same_site(mut self, same_site: SameSite) -> Self {
112 self.same_site = same_site;
113 self
114 }
115
116 #[must_use]
119 pub const fn persistent(mut self, persistent: bool) -> Self {
120 self.persistent = persistent;
121 self
122 }
123
124 pub fn cookie_name(&self) -> &str {
126 &self.name
127 }
128
129 pub fn validate(&self) -> Result<(), ConfigError> {
135 let invalid = ConfigError::InvalidCookie;
136 if self.name.is_empty() || !self.name.bytes().all(is_token_byte) {
137 return Err(invalid("name must be a non-empty HTTP token"));
138 }
139 if !self.path.starts_with('/') || !self.path.bytes().all(is_attr_byte) {
140 return Err(invalid(
141 "path must start with '/' and contain no ';' or control characters",
142 ));
143 }
144 if let Some(domain) = &self.domain
145 && (domain.is_empty() || !domain.bytes().all(is_attr_byte))
146 {
147 return Err(invalid(
148 "domain must be non-empty and contain no ';' or control characters",
149 ));
150 }
151 if self.name.starts_with("__Host-") {
152 if !self.secure {
153 return Err(invalid("__Host- cookies must be Secure"));
154 }
155 if self.domain.is_some() {
156 return Err(invalid("__Host- cookies must not have a Domain"));
157 }
158 if self.path != "/" {
159 return Err(invalid("__Host- cookies must have Path=/"));
160 }
161 }
162 if self.name.starts_with("__Secure-") && !self.secure {
163 return Err(invalid("__Secure- cookies must be Secure"));
164 }
165 if self.same_site == SameSite::None && !self.secure {
166 return Err(invalid("SameSite=None requires Secure"));
167 }
168 Ok(())
169 }
170
171 pub fn set_cookie(&self, token: &str, max_age_secs: i64) -> String {
174 let capacity =
177 self.name.len() + token.len() + self.path.len() + self.domain.as_ref().map_or(0, String::len) + 96;
178 let mut out = String::with_capacity(capacity);
179 out.push_str(&self.name);
180 out.push('=');
181 out.push_str(token);
182 self.push_attributes(&mut out, self.persistent.then_some(max_age_secs.max(0)));
183 out
184 }
185
186 pub fn clear_cookie(&self) -> String {
188 let mut out = format!("{}=", self.name);
189 self.push_attributes(&mut out, Some(0));
190 out
191 }
192
193 fn push_attributes(&self, out: &mut String, max_age: Option<i64>) {
194 let _ = write!(out, "; Path={}", self.path);
195 if let Some(domain) = &self.domain {
196 let _ = write!(out, "; Domain={domain}");
197 }
198 if let Some(max_age) = max_age {
199 let _ = write!(out, "; Max-Age={max_age}");
200 }
201 if self.http_only {
202 out.push_str("; HttpOnly");
203 }
204 if self.secure {
205 out.push_str("; Secure");
206 }
207 let _ = write!(out, "; SameSite={}", self.same_site.as_str());
208 }
209
210 pub fn find_in<'a>(&self, cookie_header: &'a str) -> Option<&'a str> {
213 find_cookie(cookie_header, &self.name)
214 }
215}
216
217pub fn find_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
219 cookie_header.split(';').find_map(|pair| {
220 let (key, value) = pair.split_once('=')?;
221 if key.trim() != name {
222 return None;
223 }
224 let value = value.trim();
225 let value = value
226 .strip_prefix('"')
227 .and_then(|v| v.strip_suffix('"'))
228 .unwrap_or(value);
229 Some(value)
230 })
231}
232
233const fn is_token_byte(b: u8) -> bool {
234 b.is_ascii_alphanumeric()
235 || matches!(
236 b,
237 b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
238 )
239}
240
241const fn is_attr_byte(b: u8) -> bool {
242 b.is_ascii_graphic() && b != b';'
243}
244
245#[cfg(test)]
246mod tests {
247 use proptest::prelude::*;
248
249 use super::*;
250
251 const TOKEN: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
252
253 #[test]
254 fn default_set_cookie_is_host_prefixed_and_locked_down() {
255 let c = CookieConfig::default();
256 assert!(c.validate().is_ok());
257 assert_eq!(
258 c.set_cookie(TOKEN, 2_592_000),
259 format!("__Host-sid={TOKEN}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax")
260 );
261 assert_eq!(
262 c.clear_cookie(),
263 "__Host-sid=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
264 );
265 }
266
267 #[test]
268 fn custom_attributes_render() {
269 let c = CookieConfig::named("sid")
270 .domain("example.com")
271 .path("/app")
272 .same_site(SameSite::Strict)
273 .http_only(false)
274 .persistent(false);
275 assert!(c.validate().is_ok());
276 assert_eq!(
277 c.set_cookie("v", 10),
278 "sid=v; Path=/app; Domain=example.com; Secure; SameSite=Strict"
279 );
280 assert_eq!(
281 c.clear_cookie(),
282 "sid=; Path=/app; Domain=example.com; Max-Age=0; Secure; SameSite=Strict"
283 );
284 }
285
286 #[test]
287 fn negative_max_age_is_clamped() {
288 assert!(CookieConfig::default().set_cookie("v", -5).contains("Max-Age=0"));
289 }
290
291 #[test]
292 fn browser_rejected_combinations_fail_validation() {
293 let bad = [
294 CookieConfig::default().secure(false),
295 CookieConfig::default().domain("example.com"),
296 CookieConfig::default().path("/app"),
297 CookieConfig::named("__Secure-sid").secure(false),
298 CookieConfig::named("sid").secure(false).same_site(SameSite::None),
299 CookieConfig::named(""),
300 CookieConfig::named("s id"),
301 CookieConfig::named("s;id"),
302 CookieConfig::named("sid").path("app"),
303 CookieConfig::named("sid").path("/a;b"),
304 CookieConfig::named("sid").domain(""),
305 CookieConfig::named("sid").domain("a.com; Secure"),
306 ];
307 for c in bad {
308 assert!(c.validate().is_err(), "{c:?}");
309 }
310 assert!(CookieConfig::named("sid").secure(false).validate().is_ok());
311 }
312
313 #[test]
314 fn finds_cookie_values() {
315 let c = CookieConfig::default();
316 assert_eq!(c.find_in("a=1; __Host-sid=abc; b=2"), Some("abc"));
317 assert_eq!(c.find_in("__Host-sid=first;__Host-sid=second"), Some("first"));
318 assert_eq!(c.find_in("__Host-sid=\"q\""), Some("q"));
319 assert_eq!(c.find_in(" __Host-sid = x "), Some("x"));
320 assert_eq!(c.find_in("x__Host-sid=1; __Host-sidx=2"), None);
321 assert_eq!(c.find_in(""), None);
322 assert_eq!(c.find_in("__Host-sid"), None);
323 assert_eq!(c.find_in("__Host-sid="), Some(""));
324 }
325
326 proptest! {
327 #[test]
328 fn find_cookie_never_panics(header in ".{0,200}", name in "[a-z_-]{1,12}") {
329 let _ = find_cookie(&header, &name);
330 }
331
332 #[test]
333 fn rendered_cookie_is_found_again(token in "[A-Za-z0-9_-]{43}") {
334 let c = CookieConfig::default();
335 let set = c.set_cookie(&token, 60);
336 let pair = set.split(';').next().unwrap();
337 let header = format!("other=1; {pair}");
338 prop_assert_eq!(c.find_in(&header), Some(token.as_str()));
339 }
340 }
341}