use std::{fmt, sync::Mutex};
use http::{HeaderMap, HeaderValue, header::SET_COOKIE};
use url::Url;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub host_only: bool,
pub path: String,
#[serde(with = "time::serde::rfc3339::option")]
pub expires: Option<time::OffsetDateTime>,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<SameSite>,
}
impl Cookie {
pub fn new(
name: impl Into<String>,
value: impl Into<String>,
domain: impl Into<String>,
) -> Self {
Self {
name: name.into(),
value: value.into(),
domain: domain.into(),
host_only: false,
path: "/".to_owned(),
expires: None,
secure: false,
http_only: false,
same_site: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
fn as_str(self) -> &'static str {
match self {
Self::Strict => "Strict",
Self::Lax => "Lax",
Self::None => "None",
}
}
}
pub struct CookieJar {
store: Mutex<cookie_store::CookieStore>,
}
impl CookieJar {
pub fn new() -> Self {
Self {
store: Mutex::new(cookie_store::CookieStore::default()),
}
}
pub fn store_response_cookies(&self, url: &Url, headers: &HeaderMap) {
let mut store = self.store.lock().unwrap_or_else(|error| error.into_inner());
for value in headers.get_all(SET_COOKIE) {
let Ok(value) = value.to_str() else {
tracing::debug!(?value, "ignoring non-text Set-Cookie header");
continue;
};
if let Err(error) = store.parse(value, url) {
tracing::debug!(%error, %url, cookie = value, "ignoring unparseable Set-Cookie header");
}
}
}
pub fn cookie_header_for(&self, url: &Url) -> Option<HeaderValue> {
let store = self.store.lock().unwrap_or_else(|error| error.into_inner());
let value = store
.get_request_values(url)
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("; ");
if value.is_empty() {
None
} else {
HeaderValue::from_str(&value).ok()
}
}
pub fn export_cookies(&self) -> Vec<Cookie> {
let store = self.store.lock().unwrap_or_else(|error| error.into_inner());
store
.iter_unexpired()
.filter_map(|stored| {
let (domain, host_only) = match &stored.domain {
cookie_store::CookieDomain::HostOnly(host) => (host.clone(), true),
cookie_store::CookieDomain::Suffix(suffix) => {
(suffix.trim_start_matches('.').to_owned(), false)
}
cookie_store::CookieDomain::NotPresent | cookie_store::CookieDomain::Empty => {
tracing::debug!(
name = stored.name(),
"skipping exported cookie without a usable domain"
);
return None;
}
};
let path = if stored.path.is_empty() {
"/".to_owned()
} else {
stored.path.to_string()
};
let expires = match &stored.expires {
cookie_store::CookieExpiration::AtUtc(expires) => Some(*expires),
cookie_store::CookieExpiration::SessionEnd => None,
};
let same_site = stored.same_site().map(|same_site| {
if same_site.is_strict() {
SameSite::Strict
} else if same_site.is_lax() {
SameSite::Lax
} else {
SameSite::None
}
});
Some(Cookie {
name: stored.name().to_owned(),
value: stored.value().to_owned(),
domain,
host_only,
path,
expires,
secure: stored.secure().unwrap_or(false),
http_only: stored.http_only().unwrap_or(false),
same_site,
})
})
.collect()
}
pub fn import_cookies(&self, cookies: &[Cookie]) -> usize {
let mut store = self.store.lock().unwrap_or_else(|error| error.into_inner());
let mut stored_count = 0;
for cookie in cookies {
if cookie.name.is_empty() || cookie.domain.is_empty() {
tracing::debug!(
name = cookie.name,
domain = cookie.domain,
"skipping imported cookie with an empty name or domain"
);
continue;
}
let path = if cookie.path.is_empty() {
"/"
} else {
cookie.path.as_str()
};
let mut builder =
cookie_store::RawCookie::build((cookie.name.clone(), cookie.value.clone()))
.path(path.to_owned())
.secure(cookie.secure)
.http_only(cookie.http_only);
if !cookie.host_only {
builder = builder.domain(cookie.domain.clone());
}
if let Some(expires) = cookie.expires {
builder = builder.expires(expires);
}
let mut raw = builder.build();
if let Some(same_site) = cookie.same_site {
let marker = format!("millipede=marker; SameSite={}", same_site.as_str());
if let Ok(parsed) = cookie_store::RawCookie::parse(marker) {
if let Some(parsed_same_site) = parsed.same_site() {
raw.set_same_site(parsed_same_site);
}
}
}
let request_url = format!(
"{}://{}{}",
if cookie.secure { "https" } else { "http" },
cookie.domain,
path
);
let request_url = match Url::parse(&request_url) {
Ok(url) => url,
Err(error) => {
tracing::debug!(%error, url = request_url, "skipping cookie with an invalid request URL");
continue;
}
};
match store.insert_raw(&raw, &request_url) {
Ok(_) => stored_count += 1,
Err(error) => {
tracing::debug!(%error, url = %request_url, name = cookie.name, "skipping cookie rejected by the store");
}
}
}
stored_count
}
pub fn to_json(&self) -> Result<String, CookieJarError> {
let store = self.store.lock().unwrap_or_else(|error| error.into_inner());
let mut buffer = Vec::new();
cookie_store::serde::json::save_incl_expired_and_nonpersistent(&store, &mut buffer)
.map_err(|error| {
let error = match error.downcast::<serde_json::Error>() {
Ok(error) => anyhow::Error::new(*error),
Err(error) => anyhow::anyhow!("{error}"),
};
CookieJarError::Serialize(error)
})?;
String::from_utf8(buffer)
.map_err(|error| CookieJarError::Serialize(anyhow::Error::new(error)))
}
pub fn from_json(json: &str) -> Result<Self, CookieJarError> {
let store = cookie_store::serde::json::load_all(json.as_bytes()).map_err(|error| {
let error = match error.downcast::<serde_json::Error>() {
Ok(error) => anyhow::Error::new(*error),
Err(error) => anyhow::anyhow!("{error}"),
};
CookieJarError::Deserialize(error)
})?;
Ok(Self {
store: Mutex::new(store),
})
}
pub fn clear(&self) {
self.store
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
}
pub fn cookie_count(&self) -> usize {
self.store
.lock()
.unwrap_or_else(|error| error.into_inner())
.iter_unexpired()
.count()
}
}
impl Default for CookieJar {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for CookieJar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CookieJar")
.field("cookie_count", &self.cookie_count())
.finish()
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CookieJarError {
#[error("failed to serialize cookie jar: {0}")]
Serialize(#[source] anyhow::Error),
#[error("failed to deserialize cookie jar: {0}")]
Deserialize(#[source] anyhow::Error),
}