leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use crate::core::error::{Error, Kind, Result};
use crate::profile::browser::digest;
use crate::profile::{Browser, ChromiumBrand, Family, Platform};

use super::builder::derive::{
    DerivedIdentity, IdentityInputs, IdentitySource, derive_identity, resolve_presented,
};
use super::{Session, SessionInner};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "SavedIdentity", into = "SavedIdentity")]
pub struct Identity {
    http: Browser,
    tls: Browser,
    platform: Platform,
    brand: Option<ChromiumBrand>,
}

#[derive(serde::Serialize, serde::Deserialize)]
struct SavedIdentity {
    http: Browser,
    tls: Browser,
    platform: Platform,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    brand: Option<ChromiumBrand>,
}

impl From<Identity> for SavedIdentity {
    fn from(id: Identity) -> Self {
        Self {
            http: id.http,
            tls: id.tls,
            platform: id.platform,
            brand: id.brand,
        }
    }
}

impl TryFrom<SavedIdentity> for Identity {
    type Error = Error;

    fn try_from(saved: SavedIdentity) -> Result<Self> {
        if saved.http.family() != saved.tls.family() {
            return Err(Error::new(Kind::Config).with_message(format!(
                "saved identity tls {} is not the same family as {}",
                saved.tls, saved.http
            )));
        }
        Ok(Self {
            http: saved.http,
            tls: saved.tls,
            platform: saved.platform,
            brand: saved.brand,
        })
    }
}

impl Identity {
    #[must_use]
    pub fn locked(browser: Browser, platform: Platform) -> Self {
        Self {
            http: browser,
            tls: browser.hello_rep(),
            platform,
            brand: None,
        }
    }

    #[must_use]
    pub fn with_brand(self, brand: ChromiumBrand) -> Self {
        Self {
            brand: Some(brand),
            ..self
        }
    }

    #[must_use]
    pub fn brand(self) -> Option<ChromiumBrand> {
        self.brand
    }

    pub fn rotate_tls(self, tls: Browser) -> Result<Self> {
        if self.http.family() != tls.family() {
            return Err(Error::new(Kind::Config).with_message(format!(
                "tls rotate {tls} is not the same family as {}",
                self.http
            )));
        }
        Ok(Self {
            tls: tls.hello_rep(),
            ..self
        })
    }

    pub fn rotate_hello(self) -> Result<Self> {
        let mut hellos: Vec<Browser> = Vec::new();
        for candidate in Browser::all() {
            if candidate.family() != self.http.family() {
                continue;
            }
            let rep = candidate.hello_rep();
            if !hellos.contains(&rep) {
                hellos.push(rep);
            }
        }
        if hellos.len() < 2 {
            return Err(Error::new(Kind::Config)
                .with_message(format!("no other hello in family {}", self.http.family())));
        }
        let current = self.tls.hello_rep();
        let index = hellos.iter().position(|&h| h == current).unwrap_or(0);
        self.rotate_tls(hellos[(index + 1) % hellos.len()])
    }

    pub fn switch_family(self, dest: Browser) -> Result<Self> {
        if dest.family() == self.http.family() {
            return Err(Error::new(Kind::Config).with_message(format!(
                "switch_family {dest} is the same family as {} — rotate_tls instead",
                self.http
            )));
        }
        let id = Self::locked(dest, self.platform);
        drop(id.user_agent()?);
        Ok(id)
    }

    #[must_use]
    pub fn http(self) -> Browser {
        self.http
    }

    #[must_use]
    pub fn tls(self) -> Browser {
        self.tls
    }

    #[must_use]
    pub fn platform(self) -> Platform {
        self.platform
    }

    pub(crate) fn user_agent(self) -> Result<String> {
        resolve_presented(
            self.http.platform_profile(self.platform),
            self.platform,
            ChromiumBrand::Chrome,
        )
        .map(|identity| identity.user_agent)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionIdentity {
    identity: Option<Identity>,
    platform: Platform,
    brand: Option<ChromiumBrand>,
    user_agent: String,
    saved: Option<Identity>,
    profile_id: Option<String>,
    profile_toml: Option<std::sync::Arc<str>>,
    languages: Option<Vec<String>>,
}

impl SessionIdentity {
    #[must_use]
    pub fn to_identity(&self) -> Option<Identity> {
        self.saved
    }

    #[must_use]
    pub fn profile_id(&self) -> Option<&str> {
        self.profile_id.as_deref()
    }

    #[must_use]
    pub fn identity(&self) -> Option<Identity> {
        self.identity
    }

    #[must_use]
    pub fn export_profile(&self) -> Option<String> {
        self.profile_toml.as_deref().map(str::to_owned)
    }

    #[must_use]
    pub fn languages(&self) -> Option<&[String]> {
        self.languages.as_deref()
    }

    #[must_use]
    pub fn browser(&self) -> Option<Browser> {
        self.identity.map(Identity::http)
    }

    #[must_use]
    pub fn platform(&self) -> Platform {
        self.platform
    }

    #[must_use]
    pub fn brand(&self) -> Option<ChromiumBrand> {
        self.brand
    }

    #[must_use]
    pub fn user_agent(&self) -> &str {
        &self.user_agent
    }
}

impl Session {
    #[must_use]
    pub fn identity(&self) -> SessionIdentity {
        let brand = match self.inner.brand {
            ChromiumBrand::Chrome => self
                .inner
                .browser
                .filter(|browser| browser.family() == Family::Chrome)
                .map(|_| ChromiumBrand::Chrome),
            other => Some(other),
        };
        let saved = self.saved_identity(brand);
        let profile_toml = self
            .inner
            .impersonates
            .then(|| self.inner.profile.source())
            .flatten()
            .map(std::sync::Arc::from);
        let profile_id = match (saved, profile_toml.as_deref()) {
            (Some(id), _) => Some(profile_id(id, self.inner.brand)),
            (None, Some(source)) => Some(source_profile_id(
                source,
                self.inner.platform,
                self.inner.brand,
            )),
            (None, None) => None,
        };
        SessionIdentity {
            identity: self.inner.identity,
            platform: self.inner.platform,
            brand,
            user_agent: self.inner.user_agent.clone(),
            saved,
            profile_id,
            profile_toml,
            languages: self.inner.languages.clone(),
        }
    }

    fn saved_identity(&self, brand: Option<ChromiumBrand>) -> Option<Identity> {
        let identity = self.inner.identity?;
        Some(Identity {
            tls: self.inner.browser?,
            brand,
            ..identity
        })
    }

    pub fn with_identity(&self, identity: Identity) -> Result<Self> {
        if self.inner.browser.is_none() {
            return Err(Error::new(Kind::Config)
                .with_message("with_identity requires an impersonating session"));
        }
        let mut session = self.clone();
        let inner = std::sync::Arc::make_mut(&mut session.inner);
        let brand = identity.brand().unwrap_or(inner.brand);
        let derived = derive_identity(IdentityInputs {
            source: IdentitySource::Browser {
                tls: identity.tls(),
                http: identity.http(),
            },
            platform: identity.platform(),
            brand,
            compression: inner.compression,
            #[cfg(feature = "http3")]
            h3_required: inner.protocol_policy.requires_h3(),
            tcp: inner.connector.tcp_profile(),
            audit: inner.audit_tls.is_some(),
            default_headers: &inner.default_headers,
            languages: inner.languages.as_deref(),
        })?;
        inner.connector = inner
            .connector
            .with_profile(&derived.profile)
            .map_err(Error::from)?;
        inner.pool = std::sync::Arc::new(inner.pool.shared_view(profile_hash(identity, brand)));
        inner.adopt(derived);
        inner.brand = brand;
        Ok(session)
    }
}

fn profile_id(identity: Identity, brand: ChromiumBrand) -> String {
    let hash = profile_hash(identity, brand);
    format!("{hash:016x}")
}

fn profile_hash(identity: Identity, brand: ChromiumBrand) -> u64 {
    let http = identity.http.profile_digest().to_le_bytes();
    let tls = identity.tls.profile_digest().to_le_bytes();
    digest(&[
        &http,
        &tls,
        identity.platform.id().as_bytes(),
        brand.id().as_bytes(),
    ])
}

fn source_profile_id(source: &str, platform: Platform, brand: ChromiumBrand) -> String {
    match Browser::matching_source(source) {
        Some(browser) => profile_id(
            Identity {
                http: browser,
                tls: browser,
                platform,
                brand: None,
            },
            brand,
        ),
        None => format!(
            "{:016x}",
            digest(&[
                source.as_bytes(),
                platform.id().as_bytes(),
                brand.id().as_bytes()
            ])
        ),
    }
}

impl SessionInner {
    fn adopt(&mut self, derived: DerivedIdentity) {
        let DerivedIdentity {
            browser,
            identity,
            platform,
            profile,
            header_style,
            header_order,
            user_agent,
            sec_ch_ua,
            accept_language,
            h2_config,
            #[cfg(feature = "http3")]
            h3_config,
            audit_tls,
        } = derived;
        self.browser = browser;
        self.identity = identity;
        self.platform = platform;
        self.profile = profile;
        self.header_style = header_style;
        self.header_order = header_order;
        self.user_agent = user_agent;
        self.sec_ch_ua = sec_ch_ua;
        self.accept_language = accept_language;
        self.h2_config = h2_config;
        #[cfg(feature = "http3")]
        {
            self.h3_config = h3_config;
        }
        self.audit_tls = audit_tls;
    }
}