Skip to main content

a3s_boot/http/
cookie.rs

1use crate::{BootError, Result};
2use std::time::Duration;
3
4/// SameSite attribute used for response cookies.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum CookieSameSite {
7    Strict,
8    Lax,
9    None,
10}
11
12impl CookieSameSite {
13    pub fn as_str(self) -> &'static str {
14        match self {
15            Self::Strict => "Strict",
16            Self::Lax => "Lax",
17            Self::None => "None",
18        }
19    }
20}
21
22/// Options used by response cookie helpers.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct CookieOptions {
25    path: Option<String>,
26    domain: Option<String>,
27    max_age: Option<u64>,
28    http_only: bool,
29    secure: bool,
30    same_site: Option<CookieSameSite>,
31}
32
33impl Default for CookieOptions {
34    fn default() -> Self {
35        Self {
36            path: Some("/".to_string()),
37            domain: None,
38            max_age: None,
39            http_only: false,
40            secure: false,
41            same_site: None,
42        }
43    }
44}
45
46impl CookieOptions {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn with_path(mut self, path: impl Into<String>) -> Self {
52        self.path = Some(path.into());
53        self
54    }
55
56    pub fn without_path(mut self) -> Self {
57        self.path = None;
58        self
59    }
60
61    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
62        self.domain = Some(domain.into());
63        self
64    }
65
66    pub fn without_domain(mut self) -> Self {
67        self.domain = None;
68        self
69    }
70
71    pub fn with_max_age(mut self, max_age: Duration) -> Self {
72        self.max_age = Some(max_age.as_secs());
73        self
74    }
75
76    pub fn with_max_age_seconds(mut self, max_age: u64) -> Self {
77        self.max_age = Some(max_age);
78        self
79    }
80
81    pub fn without_max_age(mut self) -> Self {
82        self.max_age = None;
83        self
84    }
85
86    pub fn with_http_only(mut self, http_only: bool) -> Self {
87        self.http_only = http_only;
88        self
89    }
90
91    pub fn with_secure(mut self, secure: bool) -> Self {
92        self.secure = secure;
93        self
94    }
95
96    pub fn with_same_site(mut self, same_site: CookieSameSite) -> Self {
97        self.same_site = Some(same_site);
98        self
99    }
100
101    pub fn without_same_site(mut self) -> Self {
102        self.same_site = None;
103        self
104    }
105
106    pub fn path(&self) -> Option<&str> {
107        self.path.as_deref()
108    }
109
110    pub fn domain(&self) -> Option<&str> {
111        self.domain.as_deref()
112    }
113
114    pub fn max_age_seconds(&self) -> Option<u64> {
115        self.max_age
116    }
117
118    pub fn http_only(&self) -> bool {
119        self.http_only
120    }
121
122    pub fn secure(&self) -> bool {
123        self.secure
124    }
125
126    pub fn same_site(&self) -> Option<CookieSameSite> {
127        self.same_site
128    }
129
130    pub(crate) fn set_cookie_header(&self, name: &str, value: &str) -> Result<String> {
131        validate_cookie_name(name)?;
132        validate_cookie_value(value)?;
133        self.cookie_header(name, value, self.max_age)
134    }
135
136    pub(crate) fn delete_cookie_header(&self, name: &str) -> Result<String> {
137        validate_cookie_name(name)?;
138        self.cookie_header(name, "", Some(0))
139    }
140
141    fn cookie_header(&self, name: &str, value: &str, max_age: Option<u64>) -> Result<String> {
142        let mut cookie = format!("{name}={value}");
143        if let Some(path) = &self.path {
144            validate_cookie_attribute("cookie path", path)?;
145            cookie.push_str("; Path=");
146            cookie.push_str(path);
147        }
148        if let Some(domain) = &self.domain {
149            validate_cookie_attribute("cookie domain", domain)?;
150            cookie.push_str("; Domain=");
151            cookie.push_str(domain);
152        }
153        if let Some(max_age) = max_age {
154            cookie.push_str(&format!("; Max-Age={max_age}"));
155        }
156        if self.http_only {
157            cookie.push_str("; HttpOnly");
158        }
159        if self.secure {
160            cookie.push_str("; Secure");
161        }
162        if let Some(same_site) = self.same_site {
163            cookie.push_str("; SameSite=");
164            cookie.push_str(same_site.as_str());
165        }
166        Ok(cookie)
167    }
168}
169
170fn validate_cookie_name(name: &str) -> Result<()> {
171    if name.is_empty() {
172        return Err(BootError::Internal(
173            "cookie name cannot be empty".to_string(),
174        ));
175    }
176
177    if name.bytes().all(is_cookie_name_byte) {
178        Ok(())
179    } else {
180        Err(BootError::Internal(format!(
181            "invalid cookie name {name:?}: cookie name contains invalid characters"
182        )))
183    }
184}
185
186fn validate_cookie_value(value: &str) -> Result<()> {
187    if value.bytes().all(is_cookie_value_byte) {
188        Ok(())
189    } else {
190        Err(BootError::Internal(
191            "cookie value contains invalid characters".to_string(),
192        ))
193    }
194}
195
196fn validate_cookie_attribute(name: &str, value: &str) -> Result<()> {
197    if value.is_empty() {
198        return Err(BootError::Internal(format!("{name} cannot be empty")));
199    }
200
201    if value
202        .bytes()
203        .all(|byte| matches!(byte, 0x20..=0x7e) && byte != b';')
204    {
205        Ok(())
206    } else {
207        Err(BootError::Internal(format!(
208            "{name} contains invalid characters"
209        )))
210    }
211}
212
213fn is_cookie_name_byte(byte: u8) -> bool {
214    matches!(
215        byte,
216        b'!' | b'#'
217            | b'$'
218            | b'%'
219            | b'&'
220            | b'\''
221            | b'*'
222            | b'+'
223            | b'-'
224            | b'.'
225            | b'^'
226            | b'_'
227            | b'`'
228            | b'|'
229            | b'~'
230            | b'0'..=b'9'
231            | b'a'..=b'z'
232            | b'A'..=b'Z'
233    )
234}
235
236fn is_cookie_value_byte(byte: u8) -> bool {
237    matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
238}