1use std::{fmt, sync::Mutex};
4
5use http::{HeaderMap, HeaderValue, header::SET_COOKIE};
6use url::Url;
7
8#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
24#[non_exhaustive]
25pub struct Cookie {
26 pub name: String,
28 pub value: String,
30 pub domain: String,
32 pub host_only: bool,
34 pub path: String,
36 #[serde(with = "time::serde::rfc3339::option")]
38 pub expires: Option<time::OffsetDateTime>,
39 pub secure: bool,
41 pub http_only: bool,
43 pub same_site: Option<SameSite>,
45}
46
47impl Cookie {
48 pub fn new(
50 name: impl Into<String>,
51 value: impl Into<String>,
52 domain: impl Into<String>,
53 ) -> Self {
54 Self {
55 name: name.into(),
56 value: value.into(),
57 domain: domain.into(),
58 host_only: false,
59 path: "/".to_owned(),
60 expires: None,
61 secure: false,
62 http_only: false,
63 same_site: None,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub enum SameSite {
71 Strict,
73 Lax,
75 None,
77}
78
79impl SameSite {
80 fn as_str(self) -> &'static str {
81 match self {
82 Self::Strict => "Strict",
83 Self::Lax => "Lax",
84 Self::None => "None",
85 }
86 }
87}
88
89pub struct CookieJar {
103 store: Mutex<cookie_store::CookieStore>,
104}
105
106impl CookieJar {
107 pub fn new() -> Self {
109 Self {
110 store: Mutex::new(cookie_store::CookieStore::default()),
111 }
112 }
113
114 pub fn store_response_cookies(&self, url: &Url, headers: &HeaderMap) {
116 let mut store = self.store.lock().unwrap_or_else(|error| error.into_inner());
117 for value in headers.get_all(SET_COOKIE) {
118 let Ok(value) = value.to_str() else {
119 tracing::debug!(?value, "ignoring non-text Set-Cookie header");
120 continue;
121 };
122 if let Err(error) = store.parse(value, url) {
123 tracing::debug!(%error, %url, cookie = value, "ignoring unparseable Set-Cookie header");
124 }
125 }
126 }
127
128 pub fn cookie_header_for(&self, url: &Url) -> Option<HeaderValue> {
130 let store = self.store.lock().unwrap_or_else(|error| error.into_inner());
131 let value = store
132 .get_request_values(url)
133 .map(|(name, value)| format!("{name}={value}"))
134 .collect::<Vec<_>>()
135 .join("; ");
136 if value.is_empty() {
137 None
138 } else {
139 HeaderValue::from_str(&value).ok()
140 }
141 }
142
143 pub fn export_cookies(&self) -> Vec<Cookie> {
145 let store = self.store.lock().unwrap_or_else(|error| error.into_inner());
146 store
147 .iter_unexpired()
148 .filter_map(|stored| {
149 let (domain, host_only) = match &stored.domain {
150 cookie_store::CookieDomain::HostOnly(host) => (host.clone(), true),
151 cookie_store::CookieDomain::Suffix(suffix) => {
152 (suffix.trim_start_matches('.').to_owned(), false)
153 }
154 cookie_store::CookieDomain::NotPresent | cookie_store::CookieDomain::Empty => {
155 tracing::debug!(
156 name = stored.name(),
157 "skipping exported cookie without a usable domain"
158 );
159 return None;
160 }
161 };
162 let path = if stored.path.is_empty() {
163 "/".to_owned()
164 } else {
165 stored.path.to_string()
166 };
167 let expires = match &stored.expires {
168 cookie_store::CookieExpiration::AtUtc(expires) => Some(*expires),
169 cookie_store::CookieExpiration::SessionEnd => None,
170 };
171 let same_site = stored.same_site().map(|same_site| {
172 if same_site.is_strict() {
173 SameSite::Strict
174 } else if same_site.is_lax() {
175 SameSite::Lax
176 } else {
177 SameSite::None
178 }
179 });
180
181 Some(Cookie {
182 name: stored.name().to_owned(),
183 value: stored.value().to_owned(),
184 domain,
185 host_only,
186 path,
187 expires,
188 secure: stored.secure().unwrap_or(false),
189 http_only: stored.http_only().unwrap_or(false),
190 same_site,
191 })
192 })
193 .collect()
194 }
195
196 pub fn import_cookies(&self, cookies: &[Cookie]) -> usize {
201 let mut store = self.store.lock().unwrap_or_else(|error| error.into_inner());
202 let mut stored_count = 0;
203
204 for cookie in cookies {
205 if cookie.name.is_empty() || cookie.domain.is_empty() {
206 tracing::debug!(
207 name = cookie.name,
208 domain = cookie.domain,
209 "skipping imported cookie with an empty name or domain"
210 );
211 continue;
212 }
213
214 let path = if cookie.path.is_empty() {
215 "/"
216 } else {
217 cookie.path.as_str()
218 };
219 let mut builder =
220 cookie_store::RawCookie::build((cookie.name.clone(), cookie.value.clone()))
221 .path(path.to_owned())
222 .secure(cookie.secure)
223 .http_only(cookie.http_only);
224 if !cookie.host_only {
225 builder = builder.domain(cookie.domain.clone());
226 }
227 if let Some(expires) = cookie.expires {
228 builder = builder.expires(expires);
229 }
230 let mut raw = builder.build();
231 if let Some(same_site) = cookie.same_site {
232 let marker = format!("millipede=marker; SameSite={}", same_site.as_str());
233 if let Ok(parsed) = cookie_store::RawCookie::parse(marker) {
234 if let Some(parsed_same_site) = parsed.same_site() {
235 raw.set_same_site(parsed_same_site);
236 }
237 }
238 }
239
240 let request_url = format!(
241 "{}://{}{}",
242 if cookie.secure { "https" } else { "http" },
243 cookie.domain,
244 path
245 );
246 let request_url = match Url::parse(&request_url) {
247 Ok(url) => url,
248 Err(error) => {
249 tracing::debug!(%error, url = request_url, "skipping cookie with an invalid request URL");
250 continue;
251 }
252 };
253 match store.insert_raw(&raw, &request_url) {
254 Ok(_) => stored_count += 1,
255 Err(error) => {
256 tracing::debug!(%error, url = %request_url, name = cookie.name, "skipping cookie rejected by the store");
257 }
258 }
259 }
260
261 stored_count
262 }
263
264 pub fn to_json(&self) -> Result<String, CookieJarError> {
266 let store = self.store.lock().unwrap_or_else(|error| error.into_inner());
267 let mut buffer = Vec::new();
268 cookie_store::serde::json::save_incl_expired_and_nonpersistent(&store, &mut buffer)
269 .map_err(|error| {
270 let error = match error.downcast::<serde_json::Error>() {
271 Ok(error) => anyhow::Error::new(*error),
272 Err(error) => anyhow::anyhow!("{error}"),
273 };
274 CookieJarError::Serialize(error)
275 })?;
276 String::from_utf8(buffer)
277 .map_err(|error| CookieJarError::Serialize(anyhow::Error::new(error)))
278 }
279
280 pub fn from_json(json: &str) -> Result<Self, CookieJarError> {
282 let store = cookie_store::serde::json::load_all(json.as_bytes()).map_err(|error| {
283 let error = match error.downcast::<serde_json::Error>() {
284 Ok(error) => anyhow::Error::new(*error),
285 Err(error) => anyhow::anyhow!("{error}"),
286 };
287 CookieJarError::Deserialize(error)
288 })?;
289 Ok(Self {
290 store: Mutex::new(store),
291 })
292 }
293
294 pub fn clear(&self) {
296 self.store
297 .lock()
298 .unwrap_or_else(|error| error.into_inner())
299 .clear();
300 }
301
302 pub fn cookie_count(&self) -> usize {
304 self.store
305 .lock()
306 .unwrap_or_else(|error| error.into_inner())
307 .iter_unexpired()
308 .count()
309 }
310}
311
312impl Default for CookieJar {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318impl fmt::Debug for CookieJar {
319 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320 formatter
321 .debug_struct("CookieJar")
322 .field("cookie_count", &self.cookie_count())
323 .finish()
324 }
325}
326
327#[derive(Debug, thiserror::Error)]
338#[non_exhaustive]
339pub enum CookieJarError {
340 #[error("failed to serialize cookie jar: {0}")]
342 Serialize(#[source] anyhow::Error),
343 #[error("failed to deserialize cookie jar: {0}")]
345 Deserialize(#[source] anyhow::Error),
346}