leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use std::time::{Duration, SystemTime};

use url::{Host, Url};

use crate::pool::ExpiringSet;
use crate::util::delta_seconds;

const SECURE_SCHEME: &str = "https";
const PLAIN_SCHEME: &str = "http";
const MAX_AGE: &str = "max-age";
const INCLUDE_SUBDOMAINS: &str = "includesubdomains";

#[derive(Default)]
pub(crate) struct HstsStore {
    exact: ExpiringSet<String>,
    subdomains: ExpiringSet<String>,
}

struct Policy {
    max_age: Duration,
    include_subdomains: bool,
}

impl HstsStore {
    pub(crate) fn note(&mut self, url: &Url, values: &[&str], now: SystemTime) {
        if url.scheme() != SECURE_SCHEME {
            return;
        }
        let (Some(host), Some(first)) = (domain(url), values.first()) else {
            return;
        };
        let Some(policy) = parse(first) else {
            return;
        };
        self.exact.remove(&host);
        self.subdomains.remove(&host);
        let Some(expiry) = Some(policy.max_age)
            .filter(|age| !age.is_zero())
            .and_then(|age| now.checked_add(age))
        else {
            return;
        };
        self.insert(host, policy.include_subdomains, expiry, now);
    }

    pub(crate) fn upgrade(&mut self, url: &Url, now: SystemTime) -> Option<Url> {
        if url.scheme() != PLAIN_SCHEME {
            return None;
        }
        let host = domain(url)?;
        if !self.knows(&host, now) {
            return None;
        }
        let mut upgraded = url.clone();
        upgraded.set_scheme(SECURE_SCHEME).ok()?;
        Some(upgraded)
    }

    pub(crate) fn export(&self, now: SystemTime) -> Vec<(String, bool, SystemTime)> {
        let exact = self
            .exact
            .live(now)
            .map(|(host, expiry)| (host.clone(), false, expiry));
        let subdomains = self
            .subdomains
            .live(now)
            .map(|(host, expiry)| (host.clone(), true, expiry));
        let mut entries: Vec<_> = exact.chain(subdomains).collect();
        entries.sort();
        entries
    }

    pub(crate) fn import(&mut self, entries: &[(String, bool, SystemTime)], now: SystemTime) {
        for (host, include_subdomains, expiry) in entries {
            if *expiry > now {
                self.insert(host.to_ascii_lowercase(), *include_subdomains, *expiry, now);
            }
        }
    }

    fn insert(
        &mut self,
        host: String,
        include_subdomains: bool,
        expiry: SystemTime,
        now: SystemTime,
    ) {
        if include_subdomains {
            self.exact.remove(&host);
            self.subdomains.insert(host, expiry, now);
        } else {
            self.subdomains.remove(&host);
            self.exact.insert(host, expiry, now);
        }
    }

    fn knows(&mut self, host: &str, now: SystemTime) -> bool {
        if self.exact.contains(&host.to_owned(), now) {
            return true;
        }
        let mut rest = host;
        loop {
            if self.subdomains.contains(&rest.to_owned(), now) {
                return true;
            }
            match rest.split_once('.') {
                Some((_, parent)) if !parent.is_empty() => rest = parent,
                _ => return false,
            }
        }
    }
}

fn domain(url: &Url) -> Option<String> {
    match url.host()? {
        Host::Domain(name) => Some(name.trim_end_matches('.').to_ascii_lowercase()),
        Host::Ipv4(_) | Host::Ipv6(_) => None,
    }
}

fn parse(value: &str) -> Option<Policy> {
    let mut max_age = None;
    let mut include_subdomains = false;
    for directive in value.split(';').map(str::trim).filter(|d| !d.is_empty()) {
        let (name, raw) = match directive.split_once('=') {
            Some((name, raw)) => (name.trim(), Some(raw.trim())),
            None => (directive, None),
        };
        if name.eq_ignore_ascii_case(MAX_AGE) {
            if max_age.is_some() {
                return None;
            }
            max_age = Some(seconds(raw?)?);
        } else if name.eq_ignore_ascii_case(INCLUDE_SUBDOMAINS) {
            if include_subdomains || raw.is_some() {
                return None;
            }
            include_subdomains = true;
        }
    }
    Some(Policy {
        max_age: max_age?,
        include_subdomains,
    })
}

fn seconds(raw: &str) -> Option<Duration> {
    let raw = raw
        .strip_prefix('"')
        .and_then(|r| r.strip_suffix('"'))
        .unwrap_or(raw);
    delta_seconds(raw).map(|secs| Duration::from_secs(secs.min(u64::from(u32::MAX))))
}