Skip to main content

millipede_core/
cookies.rs

1//! Synchronous session cookie storage and JSON persistence.
2
3use std::{fmt, sync::Mutex};
4
5use http::{HeaderMap, HeaderValue, header::SET_COOKIE};
6use url::Url;
7
8/// A transport-neutral HTTP cookie shared by HTTP and browser crawler contexts.
9///
10/// `domain` is stored as a bare host without a leading dot. A cookie with
11/// `host_only` set is sent only to that exact host, while `expires: None`
12/// represents a session cookie.
13///
14/// # Examples
15///
16/// ```
17/// use millipede_core::cookies::Cookie;
18///
19/// let cookie = Cookie::new("session", "abc", "example.com");
20/// assert_eq!(cookie.path, "/");
21/// assert!(!cookie.host_only);
22/// ```
23#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
24#[non_exhaustive]
25pub struct Cookie {
26    /// The cookie name.
27    pub name: String,
28    /// The cookie value.
29    pub value: String,
30    /// The bare host, without a leading dot.
31    pub domain: String,
32    /// Whether the cookie is restricted to the exact host in `domain`.
33    pub host_only: bool,
34    /// The URL path scope, normally `/`.
35    pub path: String,
36    /// The persistent expiry instant, or `None` for a session cookie.
37    #[serde(with = "time::serde::rfc3339::option")]
38    pub expires: Option<time::OffsetDateTime>,
39    /// Whether the cookie is sent only over secure transports.
40    pub secure: bool,
41    /// Whether browser scripts are prevented from reading the cookie.
42    pub http_only: bool,
43    /// The cookie's cross-site request policy, when explicitly set.
44    pub same_site: Option<SameSite>,
45}
46
47impl Cookie {
48    /// Creates a domain cookie with `/` path and no optional attributes.
49    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/// A cookie's cross-site request policy.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub enum SameSite {
71    /// Send the cookie only in same-site requests.
72    Strict,
73    /// Also send the cookie for safe top-level cross-site navigations.
74    Lax,
75    /// Permit cross-site requests; secure transport is normally required.
76    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
89/// A synchronized cookie store shared by crawler sessions.
90///
91/// The inner representation is fixed by ADR-0002; the newtype boundary keeps
92/// backend adapters from exposing the underlying cookie store or lock.
93///
94/// # Examples
95///
96/// ```
97/// use millipede_core::cookies::CookieJar;
98///
99/// let jar = CookieJar::new();
100/// assert_eq!(jar.cookie_count(), 0);
101/// ```
102pub struct CookieJar {
103    store: Mutex<cookie_store::CookieStore>,
104}
105
106impl CookieJar {
107    /// Creates an empty cookie jar.
108    pub fn new() -> Self {
109        Self {
110            store: Mutex::new(cookie_store::CookieStore::default()),
111        }
112    }
113
114    /// Parses and stores every `Set-Cookie` response header.
115    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    /// Builds the `Cookie` request header for `url`, if matching cookies exist.
129    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    /// Exports all currently unexpired cookies in the transport-neutral representation.
144    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    /// Imports cookies, merging or overwriting entries by `(name, domain, path)`.
197    ///
198    /// Invalid entries are skipped. The returned count includes only cookies that
199    /// the underlying store accepted.
200    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    /// Serializes all cookies, including session and expired cookies, to JSON.
265    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    /// Deserializes a cookie jar from its JSON representation.
281    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    /// Removes every cookie from the jar.
295    pub fn clear(&self) {
296        self.store
297            .lock()
298            .unwrap_or_else(|error| error.into_inner())
299            .clear();
300    }
301
302    /// Returns the number of unexpired cookies.
303    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/// An error serializing or deserializing a cookie jar.
328///
329/// # Examples
330///
331/// ```
332/// use millipede_core::cookies::{CookieJar, CookieJarError};
333///
334/// let result: Result<CookieJar, CookieJarError> = CookieJar::from_json("not json");
335/// assert!(result.is_err());
336/// ```
337#[derive(Debug, thiserror::Error)]
338#[non_exhaustive]
339pub enum CookieJarError {
340    /// Cookie JSON serialization failed.
341    #[error("failed to serialize cookie jar: {0}")]
342    Serialize(#[source] anyhow::Error),
343    /// Cookie JSON deserialization failed.
344    #[error("failed to deserialize cookie jar: {0}")]
345    Deserialize(#[source] anyhow::Error),
346}