leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use std::fmt;
use std::sync::{Arc, OnceLock};

use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use url::Url;

use crate::core::body_stream::BodyStream;
use crate::core::error::{Error, Kind};
use crate::trace::masked;
use crate::util::redact;

mod body;
mod download;
mod link;
mod relay;

pub use link::Link;
#[cfg(feature = "bench-internals")]
pub(crate) use link::parse_links;
pub use relay::{RelayBody, relay_headers};

pub(crate) enum ResponseBody {
    Buffered(Vec<u8>),
    Streaming(BodyStream),
    Taken,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum HttpVersion {
    Http1_1,
    Http2,
    Http3,
}

impl HttpVersion {
    pub(crate) fn ja4h_token(self) -> &'static str {
        match self {
            Self::Http1_1 => "1",
            Self::Http2 => "2",
            Self::Http3 => "3",
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Http1_1 => "HTTP/1.1",
            Self::Http2 => "HTTP/2",
            Self::Http3 => "HTTP/3",
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ResponseTiming {
    pub reused: bool,
    pub connect_ms: Option<u32>,
    pub send_ms: u32,
    pub total_ms: u32,
}

impl ResponseTiming {
    pub(crate) fn accumulator() -> Self {
        Self {
            reused: true,
            connect_ms: None,
            send_ms: 0,
            total_ms: 0,
        }
    }

    pub(crate) fn leg(started: std::time::Instant, connect_ms: Option<u32>) -> Self {
        let total_ms = Self::millis(started);
        Self {
            reused: connect_ms.is_none(),
            connect_ms,
            send_ms: total_ms.saturating_sub(connect_ms.unwrap_or(0)),
            total_ms,
        }
    }

    pub(crate) fn millis(start: std::time::Instant) -> u32 {
        u32::try_from(start.elapsed().as_millis()).unwrap_or(u32::MAX)
    }

    pub(crate) fn add_leg(&mut self, leg: &ResponseTiming) {
        self.total_ms = self.total_ms.saturating_add(leg.total_ms);
        self.send_ms = self.send_ms.saturating_add(leg.send_ms);
        if let Some(c) = leg.connect_ms {
            self.connect_ms = Some(self.connect_ms.unwrap_or(0).saturating_add(c));
        }
        self.reused &= leg.reused;
    }
}

pub struct Response {
    pub(crate) status: StatusCode,
    pub(crate) version: HttpVersion,
    pub(crate) headers: HeaderMap,
    pub(crate) trailers: Vec<(HeaderName, HeaderValue)>,
    pub(crate) body: ResponseBody,
    pub(crate) url: Url,
    pub(crate) redirect_chain: Vec<Url>,
    pub(crate) request_headers: Vec<(String, String)>,
    pub(crate) tls: Option<crate::pool::TlsInfo>,
    pub(crate) request_method: String,
    pub(crate) timing: ResponseTiming,
    pub(crate) audit_tls: Option<Arc<crate::audit::AuditTlsCache>>,
    pub(crate) audit_cache: OnceLock<crate::audit::AuditData>,
    pub(crate) compression: crate::core::CompressionConfig,
    pub(crate) attempts: u32,
    pub(crate) proxy: Option<String>,
}

impl Response {
    pub fn status(&self) -> StatusCode {
        self.status
    }

    pub fn version(&self) -> HttpVersion {
        self.version
    }

    pub fn timing(&self) -> &ResponseTiming {
        &self.timing
    }

    pub fn attempts(&self) -> u32 {
        self.attempts
    }

    pub(crate) fn set_attempts(&mut self, attempts: u32) {
        self.attempts = attempts.max(1);
    }

    pub fn proxy(&self) -> Option<&str> {
        self.proxy.as_deref()
    }

    pub(crate) fn set_proxy(&mut self, proxy: Option<&str>) {
        self.proxy = proxy.map(redact);
    }

    pub fn block(&self) -> Option<crate::core::block::BlockSignal> {
        crate::core::block::BlockRules::builtin().check(self)
    }

    pub fn url(&self) -> &Url {
        &self.url
    }

    pub fn redirect_chain(&self) -> &[Url] {
        &self.redirect_chain
    }

    pub fn headers(&self) -> &HeaderMap {
        &self.headers
    }

    pub fn trailers(&self) -> impl Iterator<Item = (&HeaderName, &HeaderValue)> {
        self.trailers.iter().map(|(k, v)| (k, v))
    }

    pub fn cookies(&self) -> impl Iterator<Item = crate::cookie::Cookie> + '_ {
        let url = &self.url;
        self.headers
            .get_all(http::header::SET_COOKIE)
            .iter()
            .filter_map(move |value| {
                crate::cookie::parse::parse_set_cookie(value.to_str().ok()?, url)
            })
    }

    pub fn tls(&self) -> Option<&crate::pool::TlsInfo> {
        self.tls.as_ref()
    }

    pub fn request_headers(&self) -> impl Iterator<Item = (&str, &str)> {
        self.request_headers
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str()))
    }

    pub fn content_length(&self) -> Option<u64> {
        self.header("content-length").and_then(|v| v.parse().ok())
    }

    pub fn error_for_status(self) -> crate::core::Result<Self> {
        match self.status_error() {
            Some(err) => Err(err),
            None => Ok(self),
        }
    }

    pub fn error_for_status_ref(&self) -> crate::core::Result<&Self> {
        match self.status_error() {
            Some(err) => Err(err),
            None => Ok(self),
        }
    }

    fn status_error(&self) -> Option<Error> {
        if self.status.as_u16() < 400 {
            return None;
        }
        Some(
            Error::new(Kind::Status)
                .with_status(self.status)
                .with_url(self.url.clone())
                .with_headers(self.headers.clone())
                .with_trail(self.attempts, self.proxy.clone()),
        )
    }

    pub fn audit(&self) -> Option<&crate::audit::AuditData> {
        let tls = self.audit_tls.as_ref()?;
        Some(self.audit_cache.get_or_init(|| {
            let ja4h = crate::audit::compute_ja4h(&crate::audit::Ja4hInput {
                method: &self.request_method,
                http_version: self.version.ja4h_token(),
                headers: &self.request_headers,
            });
            crate::audit::AuditData {
                ja4: tls.ja4.clone(),
                ja3: tls.ja3.clone(),
                h2_fingerprint: tls.h2_fingerprint.clone(),
                ja4t: tls.ja4t.clone(),
                ja4h,
                permutes_extensions: tls.permutes_extensions,
                request_headers: self.request_headers.clone(),
            }
        }))
    }

    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers.get(name).and_then(|v| v.to_str().ok())
    }
}

impl fmt::Debug for Response {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let chain: Vec<String> = self
            .redirect_chain
            .iter()
            .map(|url| redact(url.as_str()))
            .collect();
        f.debug_struct("Response")
            .field("status", &self.status)
            .field("version", &self.version)
            .field("url", &redact(self.url.as_str()))
            .field("redirect_chain", &chain)
            .field(
                "headers",
                &masked(self.headers.iter().map(|(k, v)| (k.as_str(), v.as_bytes()))),
            )
            .field(
                "trailers",
                &masked(
                    self.trailers
                        .iter()
                        .map(|(k, v)| (k.as_str(), v.as_bytes())),
                ),
            )
            .field(
                "request_headers",
                &masked(
                    self.request_headers
                        .iter()
                        .map(|(k, v)| (k.as_str(), v.as_bytes())),
                ),
            )
            .field("timing", &self.timing)
            .field("attempts", &self.attempts)
            .field("proxy", &self.proxy)
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
mod tests;