1use std::fmt::Write as _;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SameSite {
12 Strict,
14 Lax,
16 None,
18}
19
20impl SameSite {
21 fn as_str(self) -> &'static str {
22 match self {
23 SameSite::Strict => "Strict",
24 SameSite::Lax => "Lax",
25 SameSite::None => "None",
26 }
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct Cookie {
33 name: String,
34 value: String,
35 path: Option<String>,
36 domain: Option<String>,
37 max_age: Option<i64>,
38 secure: bool,
39 http_only: bool,
40 same_site: Option<SameSite>,
41}
42
43impl Cookie {
44 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
46 Self {
47 name: name.into(),
48 value: value.into(),
49 path: Some("/".into()),
50 domain: None,
51 max_age: None,
52 secure: false,
53 http_only: true,
54 same_site: Some(SameSite::Lax),
55 }
56 }
57
58 pub fn removal(name: impl Into<String>) -> Self {
63 let mut c = Self::new(name, "");
64 c.max_age = Some(0);
65 c
66 }
67
68 pub fn path(mut self, p: impl Into<String>) -> Self {
70 self.path = Some(p.into());
71 self
72 }
73 pub fn domain(mut self, d: impl Into<String>) -> Self {
75 self.domain = Some(d.into());
76 self
77 }
78 pub fn max_age(mut self, secs: i64) -> Self {
80 self.max_age = Some(secs);
81 self
82 }
83 pub fn secure(mut self, yes: bool) -> Self {
85 self.secure = yes;
86 self
87 }
88 pub fn http_only(mut self, yes: bool) -> Self {
90 self.http_only = yes;
91 self
92 }
93 pub fn same_site(mut self, s: SameSite) -> Self {
95 self.same_site = Some(s);
96 self
97 }
98
99 pub fn to_header_value(&self) -> String {
101 let mut out = String::new();
102 let _ = write!(out, "{}={}", encode(&self.name), encode(&self.value));
103 if let Some(p) = &self.path {
104 let _ = write!(out, "; Path={}", attribute_value(p));
105 }
106 if let Some(d) = &self.domain {
107 let _ = write!(out, "; Domain={}", attribute_value(d));
108 }
109 if let Some(m) = self.max_age {
110 let _ = write!(out, "; Max-Age={m}");
111 }
112 if self.secure {
113 out.push_str("; Secure");
114 }
115 if self.http_only {
116 out.push_str("; HttpOnly");
117 }
118 if let Some(s) = self.same_site {
119 let _ = write!(out, "; SameSite={}", s.as_str());
120 }
121 out
122 }
123}
124
125fn attribute_value(raw: &str) -> String {
139 raw.chars()
140 .filter(|c| *c != ';' && !c.is_control())
141 .collect()
142}
143
144fn encode(s: &str) -> String {
149 let mut out = String::with_capacity(s.len());
150 for b in s.bytes() {
151 match b {
152 b'%' => out.push_str("%25"),
156 b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~' => out.push(b as char),
157 _ => {
158 let _ = write!(out, "%{b:02X}");
159 }
160 }
161 }
162 out
163}
164
165pub(crate) fn decode(s: &str) -> String {
169 if !s.contains('%') {
170 return s.to_string();
171 }
172 let bytes = s.as_bytes();
173 let mut out = Vec::with_capacity(bytes.len());
174 let mut i = 0;
175 while i < bytes.len() {
176 if bytes[i] == b'%' && i + 2 < bytes.len() {
177 let hi = (bytes[i + 1] as char).to_digit(16);
178 let lo = (bytes[i + 2] as char).to_digit(16);
179 if let (Some(h), Some(l)) = (hi, lo) {
180 out.push((h * 16 + l) as u8);
181 i += 3;
182 continue;
183 }
184 }
185 out.push(bytes[i]);
186 i += 1;
187 }
188 String::from_utf8_lossy(&out).into_owned()
189}
190
191pub(crate) fn find<'a>(header: &'a str, name: &str) -> Option<&'a str> {
193 header.split(';').find_map(|pair| {
194 let (k, v) = pair.split_once('=')?;
195 (k.trim() == name).then_some(v.trim())
196 })
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn encodes_separators() {
205 assert_eq!(encode("a;b"), "a%3Bb");
206 assert_eq!(encode("a b"), "a%20b");
207 assert_eq!(encode("plain"), "plain");
208 }
209
210 #[test]
211 fn round_trips() {
212 for v in ["a;b c", "100%", "a%3Db", "plain", "", "ΓΌ"] {
213 assert_eq!(decode(&encode(v)), v, "round trip failed for {v:?}");
214 }
215 }
216
217 #[test]
218 fn finds_a_named_pair() {
219 assert_eq!(find("a=1; b=2", "b"), Some("2"));
220 assert_eq!(find("a=1", "missing"), None);
221 }
222}