use chrono::{TimeZone, Utc};
use reqwest::{Url, cookie::Jar};
use std::str;
#[derive(Clone, Debug)]
pub struct Cookie<'a> {
domain: &'a str,
#[allow(unused)]
include_subdomains: bool,
path: &'a str,
secure: bool,
expires: i64,
name: &'a str,
value: &'a str,
http_only: bool,
}
#[derive(Clone, Debug)]
pub struct Cookies<'a>(Vec<Cookie<'a>>);
impl<'a> std::ops::Deref for Cookies<'a> {
type Target = Vec<Cookie<'a>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> std::ops::DerefMut for Cookies<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<'a> Cookies<'a> {
pub fn parse(input: &'a [u8]) -> Result<Self, ParseError> {
let mut cookies = Vec::new();
let mut line_num = 0;
let mut start = 0;
while start < input.len() {
line_num += 1;
let end = input[start..]
.iter()
.position(|&b| b == b'\n')
.map(|p| start + p)
.unwrap_or(input.len());
let mut line = &input[start..end];
start = end + 1;
if line.ends_with(b"\r") {
line = &line[..line.len() - 1];
}
if line.is_empty() {
continue;
}
let (http_only, effective) = match line {
line if line.starts_with(b"#HttpOnly_") => (true, &line[10..]),
line if line.starts_with(b"#") => continue,
line => (false, line),
};
let parts = effective.split(|&b| b == b'\t').collect::<Vec<_>>();
if parts.len() != 7 {
return Err(ParseError::InvalidColumnParams(line_num, parts.len()));
}
cookies.push(Cookie {
domain: str::from_utf8(parts[0])?,
include_subdomains: match str::from_utf8(parts[1])? {
"TRUE" | "true" => true,
"FALSE" | "false" => false,
s => return Err(ParseError::InvalidBoolean(line_num, s.to_owned())),
},
path: str::from_utf8(parts[2])?,
secure: match str::from_utf8(parts[3])? {
"TRUE" | "true" => true,
"FALSE" | "false" => false,
s => return Err(ParseError::InvalidBoolean(line_num, s.to_owned())),
},
expires: str::from_utf8(parts[4])?.parse::<i64>().map_err(|_| {
ParseError::InvalidInteger(
line_num,
str::from_utf8(parts[4]).unwrap().to_owned(),
)
})?,
name: str::from_utf8(parts[5])?,
value: str::from_utf8(parts[6])?,
http_only,
});
}
Ok(Cookies(cookies))
}
pub fn as_netscape(&self) -> String {
let mut out = "# Netscape HTTP Cookie File\n\
# https://curl.se/docs/http-cookies.html\n\
# This file was generated by vsd! Edit at your own risk.\n\n"
.to_owned();
for c in &self.0 {
out.push_str(&c.as_netscape());
out.push('\n');
}
out
}
pub fn as_jar(&self) -> Jar {
let jar = Jar::default();
for cookie in &self.0 {
jar.add_cookie_str(&cookie.as_header(), &cookie.url().parse::<Url>().unwrap());
}
jar
}
}
impl<'a> Cookie<'a> {
pub fn as_header(&self) -> String {
let mut h = format!("{}={}", self.name, self.value);
if !self.domain.is_empty() {
h.push_str(&format!("; Domain={}", self.domain));
}
if !self.path.is_empty() {
h.push_str(&format!("; Path={}", self.path));
}
if self.expires > 0
&& let Some(dt) = Utc.timestamp_opt(self.expires, 0).single()
{
h.push_str(&format!(
"; Expires={}",
dt.format("%a, %d %b %Y %H:%M:%S GMT")
));
}
if self.secure {
h.push_str("; Secure");
}
if self.http_only {
h.push_str("; HttpOnly");
}
h
}
fn as_netscape(&self) -> String {
format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
if self.http_only {
format!("#HttpOnly_{}", self.domain)
} else {
self.domain.to_owned()
},
if self.include_subdomains {
"TRUE"
} else {
"FALSE"
},
self.path,
if self.secure { "TRUE" } else { "FALSE" },
self.expires,
self.name,
self.value
)
}
pub fn url(&self) -> String {
format!(
"{}://{}{}",
if self.secure { "https" } else { "http" },
self.domain.strip_prefix('.').unwrap_or(self.domain),
if self.path.starts_with('/') {
self.path
} else {
"/"
}
)
}
}
#[derive(Debug)]
pub enum ParseError {
InvalidBoolean(usize, String),
InvalidColumnParams(usize, usize),
InvalidInteger(usize, String),
Utf8Error(str::Utf8Error),
}
impl std::error::Error for ParseError {}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ParseError::InvalidBoolean(l, v) => write!(f, "Line {} bool: {}", l, v),
ParseError::InvalidColumnParams(l, c) => write!(f, "Line {} cols: {}", l, c),
ParseError::InvalidInteger(l, v) => write!(f, "Line {} int: {}", l, v),
ParseError::Utf8Error(e) => write!(f, "UTF-8 error: {}", e),
}
}
}
impl From<str::Utf8Error> for ParseError {
fn from(err: str::Utf8Error) -> Self {
ParseError::Utf8Error(err)
}
}
#[cfg(feature = "capture")]
use chromiumoxide::cdp::browser_protocol::network::{
Cookie as BrowserCookie, CookieParam, TimeSinceEpoch,
};
#[cfg(feature = "capture")]
impl<'a> From<&'a Vec<CookieParam>> for Cookies<'a> {
fn from(value: &'a Vec<CookieParam>) -> Self {
Cookies(
value
.iter()
.map(|c| Cookie {
domain: c.domain.as_deref().unwrap_or(""),
include_subdomains: false,
path: c.path.as_deref().unwrap_or(""),
secure: c.secure.unwrap_or(false),
expires: c.expires.as_ref().map(|x| *x.inner() as i64).unwrap_or(0),
name: &c.name,
value: &c.value,
http_only: c.http_only.unwrap_or(false),
})
.collect(),
)
}
}
#[cfg(feature = "capture")]
impl<'a> From<&'a Vec<BrowserCookie>> for Cookies<'a> {
fn from(value: &'a Vec<BrowserCookie>) -> Self {
Cookies(
value
.iter()
.map(|c| Cookie {
domain: &c.domain,
include_subdomains: false,
path: &c.path,
secure: c.secure,
expires: c.expires as i64,
name: &c.name,
value: &c.value,
http_only: c.http_only,
})
.collect(),
)
}
}
#[cfg(feature = "capture")]
impl<'a> From<Cookies<'a>> for Vec<CookieParam> {
fn from(val: Cookies<'a>) -> Self {
val.0
.into_iter()
.map(|c| CookieParam {
name: c.name.to_owned(),
value: c.value.to_owned(),
url: Some(c.url()),
domain: if c.domain.is_empty() {
None
} else {
Some(c.domain.to_owned())
},
path: if c.path.is_empty() {
None
} else {
Some(c.path.to_owned())
},
secure: Some(c.secure),
http_only: Some(c.http_only),
same_site: None,
expires: Some(TimeSinceEpoch::new(c.expires as f64)),
priority: None,
same_party: None,
source_scheme: None,
source_port: None,
partition_key: None,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse() {
let input = b"# Netscape HTTP Cookie File\n\
# This is a comment\n\
\n\
example.com\tTRUE\t/\tFALSE\t1716672000\tfoo\tbar\n\
#HttpOnly_secure.com\tFALSE\t/path\tTRUE\t0\tsession\tsecret\n";
let cookies = Cookies::parse(input).unwrap();
assert_eq!(cookies.0.len(), 2);
let c1 = &cookies.0[0];
assert_eq!(c1.domain, "example.com");
assert!(c1.include_subdomains);
assert_eq!(c1.path, "/");
assert!(!c1.secure);
assert_eq!(c1.expires, 1716672000);
assert_eq!(c1.name, "foo");
assert_eq!(c1.value, "bar");
assert!(!c1.http_only);
let c2 = &cookies.0[1];
assert_eq!(c2.domain, "secure.com");
assert!(!c2.include_subdomains);
assert_eq!(c2.path, "/path");
assert!(c2.secure);
assert_eq!(c2.expires, 0);
assert_eq!(c2.name, "session");
assert_eq!(c2.value, "secret");
assert!(c2.http_only);
}
#[test]
fn header() {
let c1 = Cookie {
domain: "example.com",
include_subdomains: true,
path: "/path",
secure: true,
expires: 1716672000,
name: "foo",
value: "bar",
http_only: true,
};
let header = c1.as_header();
assert!(header.contains("foo=bar"));
assert!(header.contains("; Domain=example.com"));
assert!(header.contains("; Path=/path"));
assert!(header.contains("; Expires=Sat, 25 May 2024 21:20:00 GMT"));
assert!(header.contains("; Secure"));
assert!(header.contains("; HttpOnly"));
}
#[test]
fn url() {
let c1 = Cookie {
domain: ".example.com",
include_subdomains: true,
path: "/path",
secure: true,
expires: 0,
name: "foo",
value: "bar",
http_only: false,
};
assert_eq!(c1.url(), "https://example.com/path");
let c2 = Cookie {
domain: "example.com",
include_subdomains: false,
path: "no_slash",
secure: false,
expires: 0,
name: "foo",
value: "bar",
http_only: false,
};
assert_eq!(c2.url(), "http://example.com/");
}
}