use std::{borrow::Cow, error::Error, fmt};
use serde::{Deserialize, Deserializer, Serialize};
pub const MAX_PROVIDER_ID_LEN: usize = 128;
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",
];
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ProviderId(Cow<'static, str>);
impl ProviderId {
pub const ALTCHA_CUSTOM: Self = Self::builtin("altcha.custom");
pub const ALTCHA_SENTINEL: Self = Self::builtin("altcha.sentinel");
pub const CAPTCHAFOX_STANDARD: Self = Self::builtin("captchafox.standard");
pub const CLOUDFLARE_TURNSTILE: Self = Self::builtin("cloudflare.turnstile");
pub const FRIENDLYCAPTCHA_V2: Self = Self::builtin("friendlycaptcha.v2");
pub const GOOGLE_RECAPTCHA_ENTERPRISE: Self = Self::builtin("google.recaptcha.enterprise");
pub const GOOGLE_RECAPTCHA_V2: Self = Self::builtin("google.recaptcha.v2");
pub const GOOGLE_RECAPTCHA_V3: Self = Self::builtin("google.recaptcha.v3");
pub const HCAPTCHA_ENTERPRISE: Self = Self::builtin("hcaptcha.enterprise");
pub const HCAPTCHA_STANDARD: Self = Self::builtin("hcaptcha.standard");
pub const MTCAPTCHA_STANDARD: Self = Self::builtin("mtcaptcha.standard");
pub const PROSOPO_PROCAPTCHA: Self = Self::builtin("prosopo.procaptcha");
const fn builtin(value: &'static str) -> Self {
Self(Cow::Borrowed(value))
}
pub fn new(value: impl Into<String>) -> Result<Self, InvalidProviderId> {
let value = value.into();
validate(&value)?;
Ok(Self(Cow::Owned(value)))
}
#[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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidProviderId {
Empty,
TooLong,
SegmentTooLong,
InvalidSyntax,
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(())
}