leyline-http 0.1.1

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

use http::{HeaderName, StatusCode};
use serde::Deserialize;

use crate::core::error::{Error, Kind, Result};
use crate::core::response::Response;

const STATUS_VENDOR: &str = "status";
const BUILTIN: &str = include_str!("../../profiles/blocks.toml");

static BUILTIN_RULES: LazyLock<BlockRules> = LazyLock::new(|| {
    BlockRules::from_toml(BUILTIN).expect("bundled block rules are statically valid")
});

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum BlockKind {
    Challenge,
    Captcha,
    Block,
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BlockSignal {
    pub vendor: String,
    pub kind: BlockKind,
}

#[derive(Debug, Clone, Default)]
pub struct BlockRules {
    rules: Arc<Vec<BlockRule>>,
}

#[derive(Debug, Clone)]
struct BlockRule {
    vendor: String,
    kind: BlockKind,
    status: Option<StatusCode>,
    header: Option<HeaderName>,
    value: Option<String>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RulesFile {
    #[serde(default)]
    rule: Vec<RuleEntry>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RuleEntry {
    vendor: String,
    kind: BlockKind,
    status: Option<u16>,
    header: Option<String>,
    value: Option<String>,
}

impl BlockRules {
    pub fn builtin() -> &'static BlockRules {
        &BUILTIN_RULES
    }

    pub fn from_toml(source: &str) -> Result<Self> {
        let file: RulesFile =
            toml::from_str(source).map_err(|e| Error::new(Kind::Config).with_source(e))?;
        let rules = file
            .rule
            .into_iter()
            .map(BlockRule::try_from)
            .collect::<Result<Vec<_>>>()?;
        Ok(Self {
            rules: Arc::new(rules),
        })
    }

    pub fn statuses(statuses: impl IntoIterator<Item = u16>) -> Self {
        let rules = statuses
            .into_iter()
            .filter_map(|code| StatusCode::from_u16(code).ok())
            .map(|status| BlockRule {
                vendor: STATUS_VENDOR.to_string(),
                kind: BlockKind::Block,
                status: Some(status),
                header: None,
                value: None,
            })
            .collect();
        Self {
            rules: Arc::new(rules),
        }
    }

    pub fn extend(&mut self, other: BlockRules) {
        Arc::make_mut(&mut self.rules).extend(other.rules.iter().cloned());
    }

    pub fn check(&self, response: &Response) -> Option<BlockSignal> {
        self.rules
            .iter()
            .find(|rule| rule.matches(response))
            .map(|rule| BlockSignal {
                vendor: rule.vendor.clone(),
                kind: rule.kind,
            })
    }
}

impl TryFrom<RuleEntry> for BlockRule {
    type Error = Error;

    fn try_from(entry: RuleEntry) -> Result<Self> {
        let invalid = |message: String| Error::new(Kind::Config).with_message(message);
        if entry.status.is_none() && entry.header.is_none() {
            return Err(invalid(format!(
                "block rule for {} needs a status or a header",
                entry.vendor
            )));
        }
        if entry.value.is_some() && entry.header.is_none() {
            return Err(invalid(format!(
                "block rule for {} has a value without a header",
                entry.vendor
            )));
        }
        let status = entry
            .status
            .map(StatusCode::from_u16)
            .transpose()
            .map_err(|e| Error::new(Kind::Config).with_source(e))?;
        let header = entry
            .header
            .map(|name| HeaderName::from_bytes(name.as_bytes()))
            .transpose()
            .map_err(|e| Error::new(Kind::Config).with_source(e))?;
        Ok(Self {
            vendor: entry.vendor,
            kind: entry.kind,
            status,
            header,
            value: entry.value,
        })
    }
}

impl BlockRule {
    fn matches(&self, response: &Response) -> bool {
        if self
            .status
            .is_some_and(|status| status != response.status())
        {
            return false;
        }
        let Some(header) = &self.header else {
            return true;
        };
        let mut values = response.headers().get_all(header).iter().peekable();
        let Some(expected) = &self.value else {
            return values.peek().is_some();
        };
        values
            .filter_map(|value| value.to_str().ok())
            .any(|value| value.trim().eq_ignore_ascii_case(expected))
    }
}