Skip to main content

captcha_sdk/
provider_id.rs

1use std::{borrow::Cow, error::Error, fmt};
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5/// Maximum accepted length of a provider identifier, in ASCII bytes.
6pub const MAX_PROVIDER_ID_LEN: usize = 128;
7
8/// Maximum accepted length of one provider identifier segment, in ASCII bytes.
9pub const MAX_PROVIDER_ID_SEGMENT_LEN: usize = 63;
10
11const RESERVED_AUTHORITIES: &[&str] = &[
12    "altcha",
13    "captchafox",
14    "cloudflare",
15    "friendlycaptcha",
16    "google",
17    "hcaptcha",
18    "mtcaptcha",
19    "prosopo",
20];
21
22const BUILTIN_IDS: &[&str] = &[
23    "altcha.custom",
24    "altcha.sentinel",
25    "captchafox.standard",
26    "cloudflare.turnstile",
27    "friendlycaptcha.v2",
28    "google.recaptcha.enterprise",
29    "google.recaptcha.v2",
30    "google.recaptcha.v3",
31    "hcaptcha.enterprise",
32    "hcaptcha.standard",
33    "mtcaptcha.standard",
34    "prosopo.procaptcha",
35];
36
37/// A stable, validated identity for a CAPTCHA provider product.
38///
39/// Identifiers consist of lowercase ASCII segments separated by dots. Each
40/// segment starts and ends with an ASCII letter or digit and may contain
41/// internal hyphens. Third-party adapters should use a reverse-DNS namespace
42/// they control, such as `com.example.captcha.product`.
43#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
44#[serde(transparent)]
45pub struct ProviderId(Cow<'static, str>);
46
47impl ProviderId {
48    /// ALTCHA custom proof-of-work verification.
49    pub const ALTCHA_CUSTOM: Self = Self::builtin("altcha.custom");
50    /// ALTCHA Sentinel verification.
51    pub const ALTCHA_SENTINEL: Self = Self::builtin("altcha.sentinel");
52    /// `CaptchaFox` standard verification.
53    pub const CAPTCHAFOX_STANDARD: Self = Self::builtin("captchafox.standard");
54    /// Cloudflare Turnstile verification.
55    pub const CLOUDFLARE_TURNSTILE: Self = Self::builtin("cloudflare.turnstile");
56    /// Friendly Captcha v2 verification.
57    pub const FRIENDLYCAPTCHA_V2: Self = Self::builtin("friendlycaptcha.v2");
58    /// Google reCAPTCHA Enterprise verification.
59    pub const GOOGLE_RECAPTCHA_ENTERPRISE: Self = Self::builtin("google.recaptcha.enterprise");
60    /// Google reCAPTCHA v2 verification.
61    pub const GOOGLE_RECAPTCHA_V2: Self = Self::builtin("google.recaptcha.v2");
62    /// Google reCAPTCHA v3 verification.
63    pub const GOOGLE_RECAPTCHA_V3: Self = Self::builtin("google.recaptcha.v3");
64    /// hCaptcha Enterprise verification.
65    pub const HCAPTCHA_ENTERPRISE: Self = Self::builtin("hcaptcha.enterprise");
66    /// hCaptcha standard verification.
67    pub const HCAPTCHA_STANDARD: Self = Self::builtin("hcaptcha.standard");
68    /// `MTCaptcha` standard verification.
69    pub const MTCAPTCHA_STANDARD: Self = Self::builtin("mtcaptcha.standard");
70    /// Prosopo Procaptcha verification.
71    pub const PROSOPO_PROCAPTCHA: Self = Self::builtin("prosopo.procaptcha");
72
73    const fn builtin(value: &'static str) -> Self {
74        Self(Cow::Borrowed(value))
75    }
76
77    /// Validates and creates an open provider identifier.
78    ///
79    /// Built-in authorities are reserved. Third-party IDs must use a namespace
80    /// controlled by the adapter author, conventionally reverse DNS.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`InvalidProviderId`] when the identifier exceeds a bound, has
85    /// invalid syntax, or claims a reserved built-in authority.
86    pub fn new(value: impl Into<String>) -> Result<Self, InvalidProviderId> {
87        let value = value.into();
88        validate(&value)?;
89        Ok(Self(Cow::Owned(value)))
90    }
91
92    /// Returns the stable textual provider identifier.
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        self.0.as_ref()
96    }
97}
98
99impl fmt::Debug for ProviderId {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter
102            .debug_tuple("ProviderId")
103            .field(&self.as_str())
104            .finish()
105    }
106}
107
108impl fmt::Display for ProviderId {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter.write_str(self.as_str())
111    }
112}
113
114impl TryFrom<&str> for ProviderId {
115    type Error = InvalidProviderId;
116
117    fn try_from(value: &str) -> Result<Self, Self::Error> {
118        Self::new(value)
119    }
120}
121
122impl<'de> Deserialize<'de> for ProviderId {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: Deserializer<'de>,
126    {
127        let value = String::deserialize(deserializer)?;
128        Self::new(value).map_err(serde::de::Error::custom)
129    }
130}
131
132/// Reason a provider identifier could not be accepted.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum InvalidProviderId {
136    /// The identifier was empty.
137    Empty,
138    /// The identifier exceeded the total length bound.
139    TooLong,
140    /// One segment exceeded the segment length bound.
141    SegmentTooLong,
142    /// The identifier did not follow the lowercase dotted syntax.
143    InvalidSyntax,
144    /// The identifier used a built-in authority for an unknown product.
145    ReservedAuthority,
146}
147
148impl fmt::Display for InvalidProviderId {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        let message = match self {
151            Self::Empty => "provider identifier must not be empty",
152            Self::TooLong => "provider identifier is too long",
153            Self::SegmentTooLong => "provider identifier segment is too long",
154            Self::InvalidSyntax => "provider identifier has invalid syntax",
155            Self::ReservedAuthority => "provider identifier uses a reserved authority",
156        };
157        formatter.write_str(message)
158    }
159}
160
161impl Error for InvalidProviderId {}
162
163fn validate(value: &str) -> Result<(), InvalidProviderId> {
164    if value.is_empty() {
165        return Err(InvalidProviderId::Empty);
166    }
167    if value.len() > MAX_PROVIDER_ID_LEN {
168        return Err(InvalidProviderId::TooLong);
169    }
170    if !value.contains('.') {
171        return Err(InvalidProviderId::InvalidSyntax);
172    }
173
174    for segment in value.split('.') {
175        validate_segment(segment)?;
176    }
177
178    let authority = value.split('.').next().expect("non-empty identifier");
179    if RESERVED_AUTHORITIES.contains(&authority) && !BUILTIN_IDS.contains(&value) {
180        return Err(InvalidProviderId::ReservedAuthority);
181    }
182
183    Ok(())
184}
185
186fn validate_segment(segment: &str) -> Result<(), InvalidProviderId> {
187    if segment.is_empty() {
188        return Err(InvalidProviderId::InvalidSyntax);
189    }
190    if segment.len() > MAX_PROVIDER_ID_SEGMENT_LEN {
191        return Err(InvalidProviderId::SegmentTooLong);
192    }
193
194    let bytes = segment.as_bytes();
195    let is_alphanumeric = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
196    if !is_alphanumeric(&bytes[0]) || !is_alphanumeric(&bytes[bytes.len() - 1]) {
197        return Err(InvalidProviderId::InvalidSyntax);
198    }
199    if !bytes
200        .iter()
201        .all(|byte| is_alphanumeric(byte) || *byte == b'-')
202    {
203        return Err(InvalidProviderId::InvalidSyntax);
204    }
205
206    Ok(())
207}