1use crate::url;
4use std::collections::BTreeMap;
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SameSite {
9 Strict,
10 Lax,
11 None,
12}
13
14impl SameSite {
15 fn as_str(self) -> &'static str {
16 match self {
17 SameSite::Strict => "Strict",
18 SameSite::Lax => "Lax",
19 SameSite::None => "None",
20 }
21 }
22}
23
24#[derive(Debug, Clone)]
29pub struct Cookie {
30 pub name: String,
31 pub value: String,
32 pub path: Option<String>,
33 pub domain: Option<String>,
34 pub max_age: Option<Duration>,
35 pub secure: bool,
36 pub http_only: bool,
37 pub same_site: Option<SameSite>,
38 expires_unix: Option<i64>,
40}
41
42impl Cookie {
43 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
44 Cookie {
45 name: name.into(),
46 value: value.into(),
47 path: Some("/".into()),
48 domain: None,
49 max_age: None,
50 secure: false,
51 http_only: true,
52 same_site: Some(SameSite::Lax),
53 expires_unix: None,
54 }
55 }
56
57 pub fn forget(name: impl Into<String>) -> Self {
59 let mut cookie = Cookie::new(name, "");
60 cookie.max_age = Some(Duration::ZERO);
61 cookie.expires_unix = Some(0);
62 cookie
63 }
64
65 pub fn path(mut self, path: impl Into<String>) -> Self {
66 self.path = Some(path.into());
67 self
68 }
69
70 pub fn domain(mut self, domain: impl Into<String>) -> Self {
71 self.domain = Some(domain.into());
72 self
73 }
74
75 pub fn max_age(mut self, age: Duration) -> Self {
76 self.max_age = Some(age);
77 self
78 }
79
80 pub fn secure(mut self, secure: bool) -> Self {
81 self.secure = secure;
82 self
83 }
84
85 pub fn http_only(mut self, http_only: bool) -> Self {
86 self.http_only = http_only;
87 self
88 }
89
90 pub fn same_site(mut self, same_site: SameSite) -> Self {
91 self.same_site = Some(same_site);
92 self
93 }
94
95 pub fn to_header(&self) -> String {
97 let mut out = format!("{}={}", self.name, url::encode(&self.value));
98
99 if let Some(path) = &self.path {
100 out.push_str("; Path=");
101 out.push_str(path);
102 }
103 if let Some(domain) = &self.domain {
104 out.push_str("; Domain=");
105 out.push_str(domain);
106 }
107 if let Some(age) = self.max_age {
108 out.push_str(&format!("; Max-Age={}", age.as_secs()));
109 }
110 if let Some(expires) = self.expires_unix {
111 out.push_str(&format!("; Expires={}", http_date(expires)));
112 }
113 if self.secure {
114 out.push_str("; Secure");
115 }
116 if self.http_only {
117 out.push_str("; HttpOnly");
118 }
119 if let Some(same_site) = self.same_site {
120 out.push_str("; SameSite=");
121 out.push_str(same_site.as_str());
122 if same_site == SameSite::None && !self.secure {
124 out.push_str("; Secure");
125 }
126 }
127 out
128 }
129}
130
131pub fn parse_header(header: &str) -> BTreeMap<String, String> {
133 header
134 .split(';')
135 .filter_map(|pair| pair.trim().split_once('='))
136 .map(|(name, value)| (name.trim().to_string(), url::decode(value.trim())))
137 .collect()
138}
139
140fn http_date(unix: i64) -> String {
142 const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
143 const MONTHS: [&str; 12] = [
144 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
145 ];
146
147 let days_since_epoch = unix.div_euclid(86_400);
148 let seconds_of_day = unix.rem_euclid(86_400);
149 let (year, month, day) = civil_from_days(days_since_epoch);
150
151 format!(
152 "{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
153 DAYS[(days_since_epoch.rem_euclid(7)) as usize],
154 day,
155 MONTHS[(month - 1) as usize],
156 year,
157 seconds_of_day / 3600,
158 (seconds_of_day % 3600) / 60,
159 seconds_of_day % 60,
160 )
161}
162
163fn civil_from_days(days: i64) -> (i64, u32, u32) {
168 let z = days + 719_468;
169 let era = z.div_euclid(146_097);
170 let day_of_era = z.rem_euclid(146_097);
171 let year_of_era =
172 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
173 let year = year_of_era + era * 400;
174 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
175 let mp = (5 * day_of_year + 2) / 153;
176 let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32;
177 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
178 (year + i64::from(month <= 2), month, day)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn defaults_are_the_hardened_ones() {
187 let header = Cookie::new("session", "abc").to_header();
188
189 assert!(header.starts_with("session=abc"));
190 assert!(header.contains("; Path=/"));
191 assert!(header.contains("; HttpOnly"));
192 assert!(header.contains("; SameSite=Lax"));
193 }
194
195 #[test]
196 fn same_site_none_forces_secure() {
197 let header = Cookie::new("x", "1").same_site(SameSite::None).to_header();
198 assert!(header.contains("; Secure"));
199 }
200
201 #[test]
202 fn values_are_encoded_and_decoded() {
203 let header = Cookie::new("greeting", "hello world").to_header();
204 assert!(header.starts_with("greeting=hello%20world"));
205
206 let parsed = parse_header("greeting=hello%20world; other=2");
207 assert_eq!(parsed["greeting"], "hello world");
208 assert_eq!(parsed["other"], "2");
209 }
210
211 #[test]
212 fn forget_expires_in_the_past() {
213 let header = Cookie::forget("session").to_header();
214 assert!(header.contains("Max-Age=0"));
215 assert!(header.contains("Expires=Thu, 01 Jan 1970 00:00:00 GMT"));
216 }
217
218 #[test]
219 fn formats_known_http_dates() {
220 assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
221 assert_eq!(http_date(1_000_000_000), "Sun, 09 Sep 2001 01:46:40 GMT");
222 }
223}