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={}", crate::date::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
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn defaults_are_the_hardened_ones() {
146 let header = Cookie::new("session", "abc").to_header();
147
148 assert!(header.starts_with("session=abc"));
149 assert!(header.contains("; Path=/"));
150 assert!(header.contains("; HttpOnly"));
151 assert!(header.contains("; SameSite=Lax"));
152 }
153
154 #[test]
155 fn same_site_none_forces_secure() {
156 let header = Cookie::new("x", "1").same_site(SameSite::None).to_header();
157 assert!(header.contains("; Secure"));
158 }
159
160 #[test]
161 fn values_are_encoded_and_decoded() {
162 let header = Cookie::new("greeting", "hello world").to_header();
163 assert!(header.starts_with("greeting=hello%20world"));
164
165 let parsed = parse_header("greeting=hello%20world; other=2");
166 assert_eq!(parsed["greeting"], "hello world");
167 assert_eq!(parsed["other"], "2");
168 }
169
170 #[test]
171 fn forget_expires_in_the_past() {
172 let header = Cookie::forget("session").to_header();
173 assert!(header.contains("Max-Age=0"));
174 assert!(header.contains("Expires=Thu, 01 Jan 1970 00:00:00 GMT"));
175 }
176
177}