use std::time::Duration;
use cookie_store::{CookieExpiration, CookieStore, RawCookie, StoreAction};
use reqwest::Url;
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
pub const QUICK: Duration = Duration::from_secs(2);
#[derive(Default)]
pub struct Jar {
store: CookieStore,
proof: Option<Proof>,
last: Option<Answer>,
}
#[derive(Clone, Debug)]
pub struct Proof {
pub path: String,
pub headers: HeaderMap,
pub at: u64,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub struct Answer {
pub status: u16,
pub at: u64,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Taken {
pub stored: usize,
pub deleted: usize,
}
impl Jar {
pub fn header(&self, url: &Url) -> Option<String> {
let pairs: Vec<String> = self.store.get_request_values(url).map(|(name, value)| format!("{name}={value}")).collect();
(!pairs.is_empty()).then(|| pairs.join("; "))
}
pub fn take<'a>(&mut self, url: &Url, set_cookies: impl IntoIterator<Item = &'a str>) -> Taken {
let mut taken = Taken::default();
for text in set_cookies {
match self.store.parse(text, url) {
Ok(StoreAction::Inserted | StoreAction::UpdatedExisting) => taken.stored += 1,
Ok(StoreAction::ExpiredExisting) => taken.deleted += 1,
Err(_) => {}
}
}
taken
}
pub fn replace(&mut self, url: &Url, pairs: Vec<(String, String)>) {
*self = Jar::default();
for (name, value) in pairs {
let cookie = RawCookie::build((name, value)).path("/").build();
let _ = self.store.insert_raw(&cookie, url);
}
}
pub fn names(&self) -> Vec<String> {
let mut names: Vec<String> = self.store.iter_unexpired().map(|cookie| cookie.name().to_string()).collect();
names.sort();
names.dedup();
names
}
pub fn is_empty(&self) -> bool {
self.store.iter_unexpired().next().is_none()
}
pub fn expires(&self) -> Option<u64> {
self.store
.iter_unexpired()
.filter_map(|cookie| match &cookie.expires {
CookieExpiration::AtUtc(at) => u64::try_from(at.unix_timestamp()).ok(),
CookieExpiration::SessionEnd => None,
})
.min()
}
pub fn answered(&mut self, answer: Answer) {
self.last = Some(answer);
}
pub fn proved(&mut self, proof: Proof) {
self.proof = Some(proof);
}
pub fn last(&self) -> Option<Answer> {
self.last
}
pub fn proof(&self) -> Option<&Proof> {
self.proof.as_ref()
}
}
pub fn proves(get: bool, sent_cookies: bool, status: u16, deleted: usize, took: Duration) -> bool {
get && sent_cookies && (200..300).contains(&status) && deleted == 0 && took <= QUICK
}
#[cfg(test)]
mod tests {
use super::*;
fn url(text: &str) -> Url {
Url::parse(text).unwrap()
}
#[test]
fn a_deleting_set_cookie_takes_the_cookie_out() {
let stand = url("https://staging.example.com/api/login");
let mut jar = Jar::default();
assert_eq!(jar.take(&stand, ["sid=abc; Path=/", "lang=en; Path=/"]), Taken { stored: 2, deleted: 0 });
assert_eq!(
jar.header(&url("https://staging.example.com/")).as_deref().map(sorted),
Some(sorted("lang=en; sid=abc"))
);
let taken = jar.take(&stand, ["sid=deleted; Path=/; Max-Age=0"]);
assert_eq!(taken, Taken { stored: 0, deleted: 1 });
assert_eq!(jar.header(&url("https://staging.example.com/")).as_deref(), Some("lang=en"));
assert_eq!(jar.names(), vec!["lang"]);
jar.take(&stand, ["lang=gone; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT"]);
assert!(jar.is_empty(), "the jar still holds {:?}", jar.names());
assert_eq!(jar.header(&url("https://staging.example.com/")), None);
}
#[test]
fn a_cookie_goes_only_where_it_belongs() {
let mut jar = Jar::default();
jar.take(
&url("https://staging.example.com/api/login"),
["token=t; Path=/api", "secret=s; Path=/; Secure"],
);
assert_eq!(
jar.header(&url("https://staging.example.com/api/user")).as_deref().map(sorted),
Some(sorted("secret=s; token=t"))
);
assert_eq!(jar.header(&url("https://staging.example.com/static/app.js")).as_deref(), Some("secret=s"));
assert_eq!(jar.header(&url("http://staging.example.com/api/user")).as_deref(), Some("token=t"));
}
#[test]
fn a_cookie_for_another_domain_is_not_kept() {
let mut jar = Jar::default();
let taken = jar.take(&url("https://staging.example.com/"), ["sid=x; Domain=other.example.org"]);
assert_eq!(taken, Taken::default());
assert!(jar.is_empty());
jar.take(&url("https://staging.example.com/"), ["sid=y; Domain=example.com; Path=/"]);
assert_eq!(jar.header(&url("https://api.example.com/")).as_deref(), Some("sid=y"));
}
#[test]
fn the_jar_knows_when_its_first_cookie_expires() {
let mut jar = Jar::default();
let stand = url("https://staging.example.com/");
jar.take(&stand, ["session=s; Path=/"]);
assert_eq!(jar.expires(), None, "a session cookie lasts as long as the gateway");
let now = crate::registry::now();
jar.take(&stand, ["short=x; Path=/; Max-Age=600; Expires=Fri, 01 Jan 2100 00:00:00 GMT"]);
let first = jar.expires().expect("a cookie with a lifetime");
assert!((now + 590..=now + 610).contains(&first), "Max-Age=600 from {now} gave {first}");
jar.take(&stand, ["long=y; Path=/; Expires=Fri, 01 Jan 2100 00:00:00 GMT"]);
assert_eq!(jar.expires(), Some(first), "the first to go is the one reported");
}
#[test]
fn cookies_from_a_browser_replace_the_jar() {
let stand = url("http://127.0.0.1:8080");
let mut jar = Jar::default();
jar.take(&stand, ["old=1; Path=/"]);
jar.answered(Answer { status: 200, at: 1 });
jar.replace(&stand, vec![("sid".into(), "abc".into()), ("lang".into(), "en".into())]);
assert_eq!(jar.names(), vec!["lang", "sid"]);
assert_eq!(
jar.header(&url("http://127.0.0.1:8080/deep/path?q=1")).as_deref().map(sorted),
Some(sorted("lang=en; sid=abc"))
);
assert_eq!(jar.last(), None, "an answer about the old session says nothing about this one");
}
#[test]
fn only_a_quick_successful_get_with_cookies_that_deletes_nothing_proves_the_session() {
let quick = Duration::from_millis(80);
assert!(proves(true, true, 200, 0, quick));
assert!(proves(true, true, 204, 0, QUICK));
assert!(!proves(false, true, 200, 0, quick), "a POST is not repeated unasked");
assert!(!proves(true, false, 200, 0, quick), "no cookies, no proof about them");
assert!(!proves(true, true, 302, 0, quick), "a redirect may be the way to the login page");
assert!(!proves(true, true, 401, 0, quick));
assert!(!proves(true, true, 200, 1, quick), "a logout link deletes the session");
assert!(!proves(true, true, 200, 0, QUICK + Duration::from_millis(1)), "a long poll");
}
fn sorted(header: &str) -> String {
let mut pairs: Vec<&str> = header.split("; ").collect();
pairs.sort_unstable();
pairs.join("; ")
}
}