captcha-sdk 0.0.1

Unified server-side CAPTCHA verification for Rust
Documentation
use std::{borrow::Cow, error::Error, fmt};

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

/// Maximum accepted length of a provider identifier, in ASCII bytes.
pub const MAX_PROVIDER_ID_LEN: usize = 128;

/// Maximum accepted length of one provider identifier segment, in ASCII bytes.
pub const MAX_PROVIDER_ID_SEGMENT_LEN: usize = 63;

const RESERVED_AUTHORITIES: &[&str] = &[
    "altcha",
    "captchafox",
    "cloudflare",
    "friendlycaptcha",
    "google",
    "hcaptcha",
    "mtcaptcha",
    "prosopo",
];

const BUILTIN_IDS: &[&str] = &[
    "altcha.custom",
    "altcha.sentinel",
    "captchafox.standard",
    "cloudflare.turnstile",
    "friendlycaptcha.v2",
    "google.recaptcha.enterprise",
    "google.recaptcha.v2",
    "google.recaptcha.v3",
    "hcaptcha.enterprise",
    "hcaptcha.standard",
    "mtcaptcha.standard",
    "prosopo.procaptcha",
];

/// A stable, validated identity for a CAPTCHA provider product.
///
/// Identifiers consist of lowercase ASCII segments separated by dots. Each
/// segment starts and ends with an ASCII letter or digit and may contain
/// internal hyphens. Third-party adapters should use a reverse-DNS namespace
/// they control, such as `com.example.captcha.product`.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ProviderId(Cow<'static, str>);

impl ProviderId {
    /// ALTCHA custom proof-of-work verification.
    pub const ALTCHA_CUSTOM: Self = Self::builtin("altcha.custom");
    /// ALTCHA Sentinel verification.
    pub const ALTCHA_SENTINEL: Self = Self::builtin("altcha.sentinel");
    /// `CaptchaFox` standard verification.
    pub const CAPTCHAFOX_STANDARD: Self = Self::builtin("captchafox.standard");
    /// Cloudflare Turnstile verification.
    pub const CLOUDFLARE_TURNSTILE: Self = Self::builtin("cloudflare.turnstile");
    /// Friendly Captcha v2 verification.
    pub const FRIENDLYCAPTCHA_V2: Self = Self::builtin("friendlycaptcha.v2");
    /// Google reCAPTCHA Enterprise verification.
    pub const GOOGLE_RECAPTCHA_ENTERPRISE: Self = Self::builtin("google.recaptcha.enterprise");
    /// Google reCAPTCHA v2 verification.
    pub const GOOGLE_RECAPTCHA_V2: Self = Self::builtin("google.recaptcha.v2");
    /// Google reCAPTCHA v3 verification.
    pub const GOOGLE_RECAPTCHA_V3: Self = Self::builtin("google.recaptcha.v3");
    /// hCaptcha Enterprise verification.
    pub const HCAPTCHA_ENTERPRISE: Self = Self::builtin("hcaptcha.enterprise");
    /// hCaptcha standard verification.
    pub const HCAPTCHA_STANDARD: Self = Self::builtin("hcaptcha.standard");
    /// `MTCaptcha` standard verification.
    pub const MTCAPTCHA_STANDARD: Self = Self::builtin("mtcaptcha.standard");
    /// Prosopo Procaptcha verification.
    pub const PROSOPO_PROCAPTCHA: Self = Self::builtin("prosopo.procaptcha");

    const fn builtin(value: &'static str) -> Self {
        Self(Cow::Borrowed(value))
    }

    /// Validates and creates an open provider identifier.
    ///
    /// Built-in authorities are reserved. Third-party IDs must use a namespace
    /// controlled by the adapter author, conventionally reverse DNS.
    ///
    /// # Errors
    ///
    /// Returns [`InvalidProviderId`] when the identifier exceeds a bound, has
    /// invalid syntax, or claims a reserved built-in authority.
    pub fn new(value: impl Into<String>) -> Result<Self, InvalidProviderId> {
        let value = value.into();
        validate(&value)?;
        Ok(Self(Cow::Owned(value)))
    }

    /// Returns the stable textual provider identifier.
    #[must_use]
    pub fn as_str(&self) -> &str {
        self.0.as_ref()
    }
}

impl fmt::Debug for ProviderId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_tuple("ProviderId")
            .field(&self.as_str())
            .finish()
    }
}

impl fmt::Display for ProviderId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl TryFrom<&str> for ProviderId {
    type Error = InvalidProviderId;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

impl<'de> Deserialize<'de> for ProviderId {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Self::new(value).map_err(serde::de::Error::custom)
    }
}

/// Reason a provider identifier could not be accepted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidProviderId {
    /// The identifier was empty.
    Empty,
    /// The identifier exceeded the total length bound.
    TooLong,
    /// One segment exceeded the segment length bound.
    SegmentTooLong,
    /// The identifier did not follow the lowercase dotted syntax.
    InvalidSyntax,
    /// The identifier used a built-in authority for an unknown product.
    ReservedAuthority,
}

impl fmt::Display for InvalidProviderId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Self::Empty => "provider identifier must not be empty",
            Self::TooLong => "provider identifier is too long",
            Self::SegmentTooLong => "provider identifier segment is too long",
            Self::InvalidSyntax => "provider identifier has invalid syntax",
            Self::ReservedAuthority => "provider identifier uses a reserved authority",
        };
        formatter.write_str(message)
    }
}

impl Error for InvalidProviderId {}

fn validate(value: &str) -> Result<(), InvalidProviderId> {
    if value.is_empty() {
        return Err(InvalidProviderId::Empty);
    }
    if value.len() > MAX_PROVIDER_ID_LEN {
        return Err(InvalidProviderId::TooLong);
    }
    if !value.contains('.') {
        return Err(InvalidProviderId::InvalidSyntax);
    }

    for segment in value.split('.') {
        validate_segment(segment)?;
    }

    let authority = value.split('.').next().expect("non-empty identifier");
    if RESERVED_AUTHORITIES.contains(&authority) && !BUILTIN_IDS.contains(&value) {
        return Err(InvalidProviderId::ReservedAuthority);
    }

    Ok(())
}

fn validate_segment(segment: &str) -> Result<(), InvalidProviderId> {
    if segment.is_empty() {
        return Err(InvalidProviderId::InvalidSyntax);
    }
    if segment.len() > MAX_PROVIDER_ID_SEGMENT_LEN {
        return Err(InvalidProviderId::SegmentTooLong);
    }

    let bytes = segment.as_bytes();
    let is_alphanumeric = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
    if !is_alphanumeric(&bytes[0]) || !is_alphanumeric(&bytes[bytes.len() - 1]) {
        return Err(InvalidProviderId::InvalidSyntax);
    }
    if !bytes
        .iter()
        .all(|byte| is_alphanumeric(byte) || *byte == b'-')
    {
        return Err(InvalidProviderId::InvalidSyntax);
    }

    Ok(())
}