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 is_secure(&self) -> bool {
135 self.secure
136 }
137
138 #[must_use]
143 pub fn build_set_cookie(&self, secret: &str) -> String {
144 let mut parts = format!(
145 "{}={}; Max-Age={}; Path={}; HttpOnly; {}",
146 self.name,
147 secret,
148 self.max_age.as_secs(),
149 self.path,
150 self.same_site.attr(),
151 );
152 if self.secure {
153 parts.push_str("; Secure");
154 }
155 if let Some(d) = &self.domain {
156 parts.push_str("; Domain=");
157 parts.push_str(d);
158 }
159 parts
160 }
161
162 #[must_use]
167 pub fn build_clear_cookie(&self) -> String {
168 let mut parts = format!(
169 "{}=; Max-Age=0; Path={}; HttpOnly; {}",
170 self.name,
171 self.path,
172 self.same_site.attr(),
173 );
174 if self.secure {
175 parts.push_str("; Secure");
176 }
177 if let Some(d) = &self.domain {
178 parts.push_str("; Domain=");
179 parts.push_str(d);
180 }
181 parts
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 const HOUR: Duration = Duration::from_secs(3_600);
190
191 fn p() -> CookiePolicy {
192 CookiePolicy::production_strict("test_sid", HOUR)
193 }
194
195 #[test]
196 fn set_cookie_contains_required_attributes() {
197 let c = p().build_set_cookie("mysecret");
198 assert!(c.contains("HttpOnly"), "missing HttpOnly");
199 assert!(c.contains("Secure"), "missing Secure");
200 assert!(c.contains("SameSite=Strict"), "missing SameSite=Strict");
201 assert!(c.contains("Path=/"), "missing Path=/");
202 assert!(c.contains("Max-Age=3600"), "missing Max-Age");
203 assert!(c.starts_with("test_sid=mysecret"), "wrong name=value");
204 }
205
206 #[test]
207 fn clear_cookie_uses_max_age_zero() {
208 let c = p().build_clear_cookie();
209 assert!(c.contains("Max-Age=0"), "clear must use Max-Age=0");
210 assert!(c.contains("HttpOnly"), "missing HttpOnly");
211 assert!(c.contains("Secure"), "missing Secure");
212 assert!(c.contains("SameSite=Strict"));
213 assert!(c.starts_with("test_sid=;"), "wrong name on clear");
214 }
215
216 #[test]
217 fn domain_omitted_by_default() {
218 let c = p().build_set_cookie("s");
219 assert!(!c.contains("Domain="), "default must omit Domain");
220 }
221
222 #[test]
223 fn explicit_domain_is_emitted() {
224 let c = p().with_domain(Some("example.com")).build_set_cookie("s");
225 assert!(c.contains("Domain=example.com"), "explicit domain missing");
226 }
227
228 #[test]
229 fn clear_cookie_mirrors_path_and_domain() {
230 let policy = CookiePolicy::production_strict("sid", HOUR)
231 .with_path("/app")
232 .with_domain(Some("example.com"));
233 let set = policy.build_set_cookie("s");
234 let clear = policy.build_clear_cookie();
235 assert!(set.contains("Path=/app"));
236 assert!(clear.contains("Path=/app"));
237 assert!(set.contains("Domain=example.com"));
238 assert!(clear.contains("Domain=example.com"));
239 }
240
241 #[test]
242 fn local_development_omits_secure() {
243 let c = CookiePolicy::local_development("dev_sid", HOUR).build_set_cookie("s");
244 assert!(!c.contains("; Secure"), "dev profile must not set Secure");
245 assert!(c.contains("HttpOnly"), "HttpOnly always required");
246 }
247
248 #[test]
249 fn lax_profile_uses_lax_samesite() {
250 let c = CookiePolicy::production_lax("sid", HOUR).build_set_cookie("s");
251 assert!(c.contains("SameSite=Lax"));
252 assert!(c.contains("; Secure"));
253 }
254
255 #[test]
256 fn secret_not_duplicated_elsewhere_in_value() {
257 let c = p().build_set_cookie("hunter2");
260 let count = c.matches("hunter2").count();
261 assert_eq!(count, 1, "secret appeared {count} times in {c:?}");
262 }
263}