leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::util::epoch_plus;

const MAX_LIFETIME: Duration = Duration::from_secs(400 * 24 * 60 * 60);
pub(crate) const MAX_NAME_VALUE_OCTETS: usize = 4096;
pub(crate) const MAX_ATTRIBUTE_OCTETS: usize = 1024;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SameSite {
    Strict,
    Lax,
    None,
}

#[derive(Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Cookie {
    pub name: String,
    pub value: String,
    pub domain: String,
    pub path: String,
    pub secure: bool,
    pub http_only: bool,
    pub same_site: SameSite,
    #[serde(with = "systime_opt_ms")]
    pub expires: Option<SystemTime>,
    #[serde(with = "systime_ms")]
    pub creation_time: SystemTime,
    #[serde(with = "systime_ms", default = "SystemTime::now")]
    pub(crate) last_access: SystemTime,
    pub host_only: bool,
}

impl std::fmt::Debug for Cookie {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Cookie")
            .field("name", &self.name)
            .field("value", &"***")
            .field("domain", &self.domain)
            .field("path", &self.path)
            .field("secure", &self.secure)
            .field("http_only", &self.http_only)
            .field("same_site", &self.same_site)
            .field("expires", &self.expires)
            .field("host_only", &self.host_only)
            .finish_non_exhaustive()
    }
}

impl Cookie {
    pub(crate) fn within_limits(&self) -> bool {
        self.name.len() + self.value.len() <= MAX_NAME_VALUE_OCTETS
            && self.domain.len() <= MAX_ATTRIBUTE_OCTETS
    }

    pub fn is_expired(&self) -> bool {
        if let Some(expires) = self.expires {
            SystemTime::now() > expires
        } else {
            false
        }
    }

    pub(crate) fn matches(&self, url_domain: &str, url_path: &str, is_secure: bool) -> bool {
        if self.secure && !is_secure {
            return false;
        }

        if self.host_only {
            if !url_domain.eq_ignore_ascii_case(&self.domain) {
                return false;
            }
        } else if !domain_match(url_domain, &self.domain) {
            return false;
        }
        path_match(url_path, &self.path)
    }

    pub(crate) fn same_slot(&self, other: &Cookie) -> bool {
        self.name == other.name && self.path == other.path && self.host_only == other.host_only
    }

    pub(crate) fn shadows_secure(&self, existing: &Cookie) -> bool {
        existing.secure
            && existing.name == self.name
            && (domain_match(&self.domain, &existing.domain)
                || domain_match(&existing.domain, &self.domain))
            && (path_match(&self.path, &existing.path) || path_match(&existing.path, &self.path))
    }
}

fn domain_match(host: &str, domain: &str) -> bool {
    let host = host.to_lowercase();
    let domain = domain.to_lowercase();
    host == domain || host.ends_with(&format!(".{domain}"))
}

fn path_match(request_path: &str, cookie_path: &str) -> bool {
    if request_path == cookie_path {
        return true;
    }
    request_path.starts_with(cookie_path)
        && (cookie_path.ends_with('/')
            || request_path.as_bytes().get(cookie_path.len()) == Some(&b'/'))
}

pub(crate) fn capped_expiry(now: SystemTime, requested: Option<SystemTime>) -> SystemTime {
    let cap = now + MAX_LIFETIME;
    requested.map_or(cap, |at| at.min(cap))
}

fn from_unix_millis(ms: i64) -> Option<SystemTime> {
    let ms = if ms < 0 { 0 } else { ms as u64 };
    epoch_plus(Duration::from_millis(ms))
}

fn unix_millis(t: &SystemTime) -> i64 {
    t.duration_since(UNIX_EPOCH).map_or(0, |since| {
        i64::try_from(since.as_millis()).unwrap_or(i64::MAX)
    })
}

mod systime_ms {
    use super::*;

    pub(super) fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_i64(unix_millis(t))
    }

    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
        let ms = i64::deserialize(d)?;
        Ok(from_unix_millis(ms).unwrap_or_else(SystemTime::now))
    }
}

mod systime_opt_ms {
    use super::*;

    pub(super) fn serialize<S: Serializer>(
        t: &Option<SystemTime>,
        s: S,
    ) -> Result<S::Ok, S::Error> {
        match t {
            Some(t) => s.serialize_some(&unix_millis(t)),
            None => s.serialize_none(),
        }
    }

    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
        d: D,
    ) -> Result<Option<SystemTime>, D::Error> {
        let opt = Option::<i64>::deserialize(d)?;
        Ok(opt.map(|ms| capped_expiry(SystemTime::now(), from_unix_millis(ms))))
    }
}