1use std::time::Duration;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SameSitePolicy {
14 #[default]
17 Strict,
18 Lax,
22 None,
25}
26
27impl SameSitePolicy {
28 #[must_use]
30 pub const fn attr(self) -> &'static str {
31 match self {
32 SameSitePolicy::Strict => "SameSite=Strict",
33 SameSitePolicy::Lax => "SameSite=Lax",
34 SameSitePolicy::None => "SameSite=None",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum CookieProfile {
42 #[default]
44 ProductionStrict,
45 ProductionLax,
47 LocalDevelopment,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct CookiePolicy {
60 name: String,
61 path: String,
62 max_age: Duration,
63 same_site: SameSitePolicy,
64 secure: bool,
65 domain: Option<String>,
66}
67
68impl CookiePolicy {
69 #[must_use]
73 pub fn production_strict(name: impl Into<String>, max_age: Duration) -> Self {
74 Self {
75 name: name.into(),
76 path: "/".to_string(),
77 max_age,
78 same_site: SameSitePolicy::Strict,
79 secure: true,
80 domain: None,
81 }
82 }
83
84 #[must_use]
86 pub fn production_lax(name: impl Into<String>, max_age: Duration) -> Self {
87 Self {
88 name: name.into(),
89 path: "/".to_string(),
90 max_age,
91 same_site: SameSitePolicy::Lax,
92 secure: true,
93 domain: None,
94 }
95 }
96
97 #[must_use]
100 pub fn local_development(name: impl Into<String>, max_age: Duration) -> Self {
101 Self {
102 name: name.into(),
103 path: "/".to_string(),
104 max_age,
105 same_site: SameSitePolicy::Lax,
106 secure: false,
107 domain: None,
108 }
109 }
110
111 #[must_use]
113 pub fn with_path(mut self, path: impl Into<String>) -> Self {
114 self.path = path.into();
115 self
116 }
117
118 #[must_use]
121 pub fn with_domain(mut self, domain: Option<impl Into<String>>) -> Self {
122 self.domain = domain.map(Into::into);
123 self
124 }
125
126 #[must_use]
128 pub fn name(&self) -> &str {
129 &self.name
130 }
131
132 #[must_use]
134 pub fn max_age_duration(&self) -> std::time::Duration {
135 self.max_age
136 }
137
138 #[must_use]
140 pub fn is_secure(&self) -> bool {
141 self.secure
142 }
143
144 #[must_use]
149 pub fn build_set_cookie(&self, secret: &str) -> String {
150 let mut parts = format!(
151 "{}={}; Max-Age={}; Path={}; HttpOnly; {}",
152 self.name,
153 secret,
154 self.max_age.as_secs(),
155 self.path,
156 self.same_site.attr(),
157 );
158 if self.secure {
159 parts.push_str("; Secure");
160 }
161 if let Some(d) = &self.domain {
162 parts.push_str("; Domain=");
163 parts.push_str(d);
164 }
165 parts
166 }
167
168 #[must_use]
173 pub fn build_clear_cookie(&self) -> String {
174 let mut parts = format!(
175 "{}=; Max-Age=0; Path={}; HttpOnly; {}",
176 self.name,
177 self.path,
178 self.same_site.attr(),
179 );
180 if self.secure {
181 parts.push_str("; Secure");
182 }
183 if let Some(d) = &self.domain {
184 parts.push_str("; Domain=");
185 parts.push_str(d);
186 }
187 parts
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 const HOUR: Duration = Duration::from_secs(3_600);
196
197 fn p() -> CookiePolicy {
198 CookiePolicy::production_strict("test_sid", HOUR)
199 }
200
201 #[test]
202 fn set_cookie_contains_required_attributes() {
203 let c = p().build_set_cookie("mysecret");
204 assert!(c.contains("HttpOnly"), "missing HttpOnly");
205 assert!(c.contains("Secure"), "missing Secure");
206 assert!(c.contains("SameSite=Strict"), "missing SameSite=Strict");
207 assert!(c.contains("Path=/"), "missing Path=/");
208 assert!(c.contains("Max-Age=3600"), "missing Max-Age");
209 assert!(c.starts_with("test_sid=mysecret"), "wrong name=value");
210 }
211
212 #[test]
213 fn clear_cookie_uses_max_age_zero() {
214 let c = p().build_clear_cookie();
215 assert!(c.contains("Max-Age=0"), "clear must use Max-Age=0");
216 assert!(c.contains("HttpOnly"), "missing HttpOnly");
217 assert!(c.contains("Secure"), "missing Secure");
218 assert!(c.contains("SameSite=Strict"));
219 assert!(c.starts_with("test_sid=;"), "wrong name on clear");
220 }
221
222 #[test]
223 fn domain_omitted_by_default() {
224 let c = p().build_set_cookie("s");
225 assert!(!c.contains("Domain="), "default must omit Domain");
226 }
227
228 #[test]
229 fn explicit_domain_is_emitted() {
230 let c = p().with_domain(Some("example.com")).build_set_cookie("s");
231 assert!(c.contains("Domain=example.com"), "explicit domain missing");
232 }
233
234 #[test]
235 fn clear_cookie_mirrors_path_and_domain() {
236 let policy = CookiePolicy::production_strict("sid", HOUR)
237 .with_path("/app")
238 .with_domain(Some("example.com"));
239 let set = policy.build_set_cookie("s");
240 let clear = policy.build_clear_cookie();
241 assert!(set.contains("Path=/app"));
242 assert!(clear.contains("Path=/app"));
243 assert!(set.contains("Domain=example.com"));
244 assert!(clear.contains("Domain=example.com"));
245 }
246
247 #[test]
248 fn local_development_omits_secure() {
249 let c = CookiePolicy::local_development("dev_sid", HOUR).build_set_cookie("s");
250 assert!(!c.contains("; Secure"), "dev profile must not set Secure");
251 assert!(c.contains("HttpOnly"), "HttpOnly always required");
252 }
253
254 #[test]
255 fn lax_profile_uses_lax_samesite() {
256 let c = CookiePolicy::production_lax("sid", HOUR).build_set_cookie("s");
257 assert!(c.contains("SameSite=Lax"));
258 assert!(c.contains("; Secure"));
259 }
260
261 #[test]
262 fn secret_not_duplicated_elsewhere_in_value() {
263 let c = p().build_set_cookie("hunter2");
266 let count = c.matches("hunter2").count();
267 assert_eq!(count, 1, "secret appeared {count} times in {c:?}");
268 }
269}