Skip to main content

stygian_charon/vendor_classifier/
vendor.rs

1//! Vendor taxonomy and TOML-loadable definitions (T89).
2//!
3//! The [`VendorId`] enum is the **stable, wire-level identifier**
4//! for every anti-bot vendor the classifier knows about. Adding a
5//! new variant is a breaking change for downstream consumers
6//! (e.g. `VendorClassification` JSON payloads), so the taxonomy is
7//! intentionally small and uses `#[serde(rename_all = "snake_case")]`
8//! for predictable wire labels.
9//!
10//! ## Tier 1 (always shipped)
11//!
12//! The four Tier 1 vendors are documented in
13//! `crates/stygian-charon/data/vendors/` and embedded into the
14//! binary at compile time via `include_str!`. Their TOML payload
15//! is the single source of truth for the per-vendor signal
16//! catalogue; the enum below is the wire/lookup contract.
17//!
18//! | `VendorId`     | Display name                | TOML file                        |
19//! |----------------|-----------------------------|----------------------------------|
20//! | `DataDome`     | `DataDome`                  | `data/vendors/datadome.toml`     |
21//! | `PerimeterX`   | `PerimeterX` / HUMAN Security | `data/vendors/perimeter_x.toml`  |
22//! | `Akamai`       | `Akamai` Bot Manager        | `data/vendors/akamai.toml`       |
23//! | `Cloudflare`   | `Cloudflare`                | `data/vendors/cloudflare.toml`   |
24//!
25//! ## Tier 2 (taxonomy-only, no baseline signals)
26//!
27//! `Hcaptcha`, `Recaptcha`, `Kasada`, `FingerprintCom`,
28//! `ShapeSecurity`, and `Imperva` are present in the enum so
29//! downstream T88/T90 layers can name them, but no baseline
30//! signals ship for them — operators must register their own
31//! signal catalogue via
32//! [`VendorDefinition`][crate::vendor_classifier::VendorDefinition].
33//!
34//! ## Unknown
35//!
36//! `Unknown` is the catch-all variant used when no vendor matched
37//! or when no classification can be produced. It must remain the
38//! **last** variant so it sorts last in the
39//! deterministic tie-break rule (see
40//! [`crate::vendor_classifier::VendorClassification`]).
41
42use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::vendor_classifier::error::VendorError;
47use crate::vendor_classifier::evidence::EvidenceSource;
48
49/// Stable identifier for an anti-bot vendor.
50///
51/// The discriminant order is **significant**: it is the
52/// deterministic tie-break rule for the classifier. When two
53/// vendors tie on the top score, the lower discriminant
54/// (`Akamai` < `Cloudflare` < `DataDome` < `PerimeterX` < …)
55/// wins.
56///
57/// # Example
58///
59/// ```
60/// use stygian_charon::vendor_classifier::VendorId;
61///
62/// let v = VendorId::DataDome;
63/// assert_eq!(v.label(), "datadome");
64/// assert_eq!(v.tier(), 1);
65/// ```
66#[derive(
67    Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
68)]
69#[serde(rename_all = "snake_case")]
70pub enum VendorId {
71    /// `Akamai` Bot Manager (`_abck`, `bm_sz`).
72    Akamai,
73    /// `Cloudflare` bot management (`cf-ray`, `__cf_bm`).
74    Cloudflare,
75    /// `DataDome` (`datadome=`, `x-datadome`).
76    DataDome,
77    /// `PerimeterX` / HUMAN Security (`_px3`, `_px2`).
78    PerimeterX,
79    /// hCaptcha challenge provider.
80    Hcaptcha,
81    /// Google reCAPTCHA challenge provider.
82    Recaptcha,
83    /// Kasada challenge provider.
84    Kasada,
85    /// Fingerprint.com identification.
86    FingerprintCom,
87    /// Shape Security (F5).
88    ShapeSecurity,
89    /// Imperva (Incapsula) bot management.
90    Imperva,
91    /// Fastly bot management.
92    Fastly,
93    /// Catch-all when no vendor matched.
94    #[default]
95    Unknown,
96}
97
98impl VendorId {
99    /// Stable, lower-case wire label.
100    ///
101    /// # Example
102    ///
103    /// ```
104    /// use stygian_charon::vendor_classifier::VendorId;
105    ///
106    /// assert_eq!(VendorId::DataDome.label(), "datadome");
107    /// assert_eq!(VendorId::PerimeterX.label(), "perimeter_x");
108    /// assert_eq!(VendorId::Cloudflare.label(), "cloudflare");
109    /// assert_eq!(VendorId::Akamai.label(), "akamai");
110    /// ```
111    #[must_use]
112    pub const fn label(self) -> &'static str {
113        match self {
114            Self::Akamai => "akamai",
115            Self::Cloudflare => "cloudflare",
116            Self::DataDome => "datadome",
117            Self::PerimeterX => "perimeter_x",
118            Self::Hcaptcha => "hcaptcha",
119            Self::Recaptcha => "recaptcha",
120            Self::Kasada => "kasada",
121            Self::FingerprintCom => "fingerprint_com",
122            Self::ShapeSecurity => "shape_security",
123            Self::Imperva => "imperva",
124            Self::Fastly => "fastly",
125            Self::Unknown => "unknown",
126        }
127    }
128
129    /// Tier number (1 = always shipped, 2 = taxonomy-only, 0 = unknown).
130    ///
131    /// # Example
132    ///
133    /// ```
134    /// use stygian_charon::vendor_classifier::VendorId;
135    ///
136    /// assert_eq!(VendorId::DataDome.tier(), 1);
137    /// assert_eq!(VendorId::Cloudflare.tier(), 1);
138    /// assert_eq!(VendorId::Akamai.tier(), 1);
139    /// assert_eq!(VendorId::PerimeterX.tier(), 1);
140    /// assert_eq!(VendorId::Unknown.tier(), 0);
141    /// ```
142    #[must_use]
143    pub const fn tier(self) -> u8 {
144        match self {
145            Self::DataDome | Self::PerimeterX | Self::Akamai | Self::Cloudflare => 1,
146            Self::Hcaptcha
147            | Self::Recaptcha
148            | Self::Kasada
149            | Self::FingerprintCom
150            | Self::ShapeSecurity
151            | Self::Imperva
152            | Self::Fastly => 2,
153            Self::Unknown => 0,
154        }
155    }
156
157    /// Parse a [`VendorId`] from its [`label`][Self::label].
158    ///
159    /// # Example
160    ///
161    /// ```
162    /// use stygian_charon::vendor_classifier::VendorId;
163    ///
164    /// assert_eq!(VendorId::from_label("datadome"), Some(VendorId::DataDome));
165    /// assert_eq!(VendorId::from_label("cloudflare"), Some(VendorId::Cloudflare));
166    /// assert_eq!(VendorId::from_label("nope"), None);
167    /// ```
168    #[must_use]
169    pub fn from_label(label: &str) -> Option<Self> {
170        match label {
171            "akamai" => Some(Self::Akamai),
172            "cloudflare" => Some(Self::Cloudflare),
173            "datadome" => Some(Self::DataDome),
174            "perimeter_x" => Some(Self::PerimeterX),
175            "hcaptcha" => Some(Self::Hcaptcha),
176            "recaptcha" => Some(Self::Recaptcha),
177            "kasada" => Some(Self::Kasada),
178            "fingerprint_com" => Some(Self::FingerprintCom),
179            "shape_security" => Some(Self::ShapeSecurity),
180            "imperva" => Some(Self::Imperva),
181            "fastly" => Some(Self::Fastly),
182            "unknown" => Some(Self::Unknown),
183            _ => None,
184        }
185    }
186}
187
188/// One signal row from a vendor definition's `[[signals]]` table.
189///
190/// A signal is the smallest unit the classifier matches against the
191/// input strings (cookies, headers, challenge URLs, body markers,
192/// scripts). Patterns are matched **case-insensitively** — the
193/// loader lower-cases them at load time so the per-request
194/// classification hot path never has to.
195///
196/// # Example
197///
198/// ```
199/// use stygian_charon::vendor_classifier::{EvidenceSource, VendorSignal};
200///
201/// let s = VendorSignal {
202///     pattern: "x-datadome".to_string(),
203///     source: EvidenceSource::Header,
204///     weight: 5,
205/// };
206/// assert_eq!(s.weight, 5);
207/// ```
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209pub struct VendorSignal {
210    /// Literal pattern to search for (case-insensitive).
211    pub pattern: String,
212    /// Which input channel the pattern is matched against.
213    pub source: EvidenceSource,
214    /// Weight contributed to the vendor score on a hit.
215    pub weight: u32,
216}
217
218/// One vendor's signal catalogue. Multiple vendors can ship
219/// definitions; the [`crate::vendor_classifier::VendorClassifier`]
220/// consumes them all and ranks the matches.
221///
222/// Definitions are loaded from TOML at compile time via
223/// `include_str!`. The schema is
224/// `serde::Deserialize` so the same TOML files double as the
225/// operator-facing configuration surface.
226///
227/// # Example
228///
229/// ```
230/// use stygian_charon::vendor_classifier::{VendorDefinition, VendorId, VendorSignal, EvidenceSource};
231///
232/// let def = VendorDefinition {
233///     id: VendorId::DataDome,
234///     display_name: "DataDome".to_string(),
235///     description: "baseline".to_string(),
236///     tier: 1,
237///     signals: vec![VendorSignal {
238///         pattern: "x-datadome".to_string(),
239///         source: EvidenceSource::Header,
240///         weight: 5,
241///     }],
242/// };
243/// assert!(def.validate().is_ok());
244/// ```
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct VendorDefinition {
247    /// Vendor identifier from the [`VendorId`] enum.
248    pub id: VendorId,
249    /// Human-readable display name (used in operator logs).
250    pub display_name: String,
251    /// Short description of the vendor stack.
252    #[serde(default)]
253    pub description: String,
254    /// Tier (1 = always shipped, 2 = taxonomy-only).
255    pub tier: u8,
256    /// Signal catalogue.
257    #[serde(default)]
258    pub signals: Vec<VendorSignal>,
259}
260
261impl VendorDefinition {
262    /// Validate the definition's internal consistency.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`VendorError`] on the first inconsistency. The
267    /// error embeds the field path and the bad value so operators
268    /// can locate the offending TOML line without re-running the
269    /// loader.
270    pub fn validate(&self) -> Result<(), VendorError> {
271        if self.display_name.trim().is_empty() {
272            return Err(VendorError::invalid_field(
273                self.id.label(),
274                "display_name",
275                self.display_name.clone(),
276                "display_name must be a non-empty string",
277            ));
278        }
279        if !(0..=2).contains(&self.tier) {
280            return Err(VendorError::invalid_field(
281                self.id.label(),
282                "tier",
283                self.tier,
284                "tier must be 0 (unknown), 1 (baseline), or 2 (taxonomy-only)",
285            ));
286        }
287        for (i, sig) in self.signals.iter().enumerate() {
288            if sig.pattern.trim().is_empty() {
289                return Err(VendorError::invalid_field(
290                    self.id.label(),
291                    format!("signals[{i}].pattern"),
292                    sig.pattern.clone(),
293                    "pattern must be a non-empty string",
294                ));
295            }
296            if sig.weight == 0 {
297                return Err(VendorError::invalid_field(
298                    self.id.label(),
299                    format!("signals[{i}].weight"),
300                    sig.weight,
301                    "weight must be > 0",
302                ));
303            }
304        }
305        Ok(())
306    }
307
308    /// Return the signals, indexed by [`EvidenceSource`] for fast
309    /// classification.
310    #[must_use]
311    pub fn signals_by_source(&self) -> BTreeMap<EvidenceSource, Vec<&VendorSignal>> {
312        let mut grouped: BTreeMap<EvidenceSource, Vec<&VendorSignal>> = BTreeMap::new();
313        for sig in &self.signals {
314            grouped.entry(sig.source).or_default().push(sig);
315        }
316        grouped
317    }
318}
319
320/// Parse a raw TOML payload into a [`VendorDefinition`].
321///
322/// The TOML is expected to declare the `id` field as the lower-case
323/// `VendorId` label (e.g. `"datadome"`). The loader maps that label
324/// into a [`VendorId`] discriminant and rejects unknown ids with
325/// [`VendorError::UnknownVendorId`].
326///
327/// # Errors
328///
329/// Returns [`VendorError`] when the TOML fails to parse, the
330/// declared id is not part of the supported taxonomy, or the
331/// resulting [`VendorDefinition`] fails [`validate`][VendorDefinition::validate].
332pub fn parse_vendor_definition(toml_text: &str) -> Result<VendorDefinition, VendorError> {
333    #[derive(Deserialize)]
334    struct RawDefinition {
335        id: String,
336        display_name: String,
337        #[serde(default)]
338        description: String,
339        #[serde(default = "default_tier")]
340        tier: u8,
341        #[serde(default)]
342        signals: Vec<VendorSignal>,
343    }
344
345    let raw: RawDefinition = toml::from_str(toml_text)?;
346    let id = VendorId::from_label(&raw.id).ok_or_else(|| VendorError::UnknownVendorId {
347        vendor_id: raw.id.clone(),
348    })?;
349    let def = VendorDefinition {
350        id,
351        display_name: raw.display_name,
352        description: raw.description,
353        tier: raw.tier,
354        signals: raw
355            .signals
356            .into_iter()
357            .map(|mut s| {
358                s.pattern = s.pattern.to_ascii_lowercase();
359                s
360            })
361            .collect(),
362    };
363    def.validate()?;
364    Ok(def)
365}
366
367const fn default_tier() -> u8 {
368    1
369}
370
371#[cfg(test)]
372#[allow(
373    clippy::unwrap_used,
374    clippy::expect_used,
375    clippy::panic,
376    clippy::indexing_slicing
377)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn vendor_id_labels_round_trip() {
383        for v in [
384            VendorId::Akamai,
385            VendorId::Cloudflare,
386            VendorId::DataDome,
387            VendorId::PerimeterX,
388            VendorId::Hcaptcha,
389            VendorId::Recaptcha,
390            VendorId::Kasada,
391            VendorId::FingerprintCom,
392            VendorId::ShapeSecurity,
393            VendorId::Imperva,
394            VendorId::Unknown,
395        ] {
396            assert_eq!(VendorId::from_label(v.label()), Some(v));
397        }
398    }
399
400    #[test]
401    fn vendor_id_unknown_label_returns_none() {
402        assert_eq!(VendorId::from_label("nope"), None);
403        assert_eq!(VendorId::from_label(""), None);
404        assert_eq!(VendorId::from_label("DataDome"), None); // case-sensitive
405    }
406
407    #[test]
408    fn vendor_id_tier_matches_taxonomy_table() {
409        assert_eq!(VendorId::DataDome.tier(), 1);
410        assert_eq!(VendorId::PerimeterX.tier(), 1);
411        assert_eq!(VendorId::Akamai.tier(), 1);
412        assert_eq!(VendorId::Cloudflare.tier(), 1);
413        assert_eq!(VendorId::Hcaptcha.tier(), 2);
414        assert_eq!(VendorId::Recaptcha.tier(), 2);
415        assert_eq!(VendorId::Unknown.tier(), 0);
416    }
417
418    #[test]
419    fn definition_rejects_empty_display_name() {
420        let def = VendorDefinition {
421            id: VendorId::DataDome,
422            display_name: String::new(),
423            description: String::new(),
424            tier: 1,
425            signals: Vec::new(),
426        };
427        let err = def.validate().expect_err("empty display_name");
428        assert_eq!(err.field_path(), Some("display_name"));
429    }
430
431    #[test]
432    fn definition_rejects_out_of_range_tier() {
433        let def = VendorDefinition {
434            id: VendorId::DataDome,
435            display_name: "x".to_string(),
436            description: String::new(),
437            tier: 9,
438            signals: Vec::new(),
439        };
440        let err = def.validate().expect_err("bad tier");
441        assert_eq!(err.field_path(), Some("tier"));
442    }
443
444    #[test]
445    fn definition_rejects_empty_pattern() {
446        let def = VendorDefinition {
447            id: VendorId::DataDome,
448            display_name: "x".to_string(),
449            description: String::new(),
450            tier: 1,
451            signals: vec![VendorSignal {
452                pattern: String::new(),
453                source: EvidenceSource::Header,
454                weight: 5,
455            }],
456        };
457        let err = def.validate().expect_err("empty pattern");
458        assert!(err.field_path().is_some_and(|p| p.contains("signals[0]")));
459    }
460
461    #[test]
462    fn definition_rejects_zero_weight() {
463        let def = VendorDefinition {
464            id: VendorId::DataDome,
465            display_name: "x".to_string(),
466            description: String::new(),
467            tier: 1,
468            signals: vec![VendorSignal {
469                pattern: "x".to_string(),
470                source: EvidenceSource::Header,
471                weight: 0,
472            }],
473        };
474        let err = def.validate().expect_err("zero weight");
475        assert!(err.field_path().is_some_and(|p| p.contains("signals[0]")));
476    }
477
478    #[test]
479    fn parse_vendor_definition_round_trips_through_toml() {
480        let toml_text = r#"
481id = "datadome"
482display_name = "DataDome"
483description = "test"
484tier = 1
485
486[[signals]]
487pattern = "X-DATADOME"
488source = "header"
489weight = 5
490"#;
491        let def = parse_vendor_definition(toml_text).expect("parse");
492        assert_eq!(def.id, VendorId::DataDome);
493        assert_eq!(def.tier, 1);
494        // Patterns are case-folded at load time.
495        assert_eq!(def.signals[0].pattern, "x-datadome");
496    }
497
498    #[test]
499    fn parse_vendor_definition_rejects_unknown_id() {
500        let toml_text = r#"
501id = "nope"
502display_name = "Nope"
503tier = 1
504"#;
505        let err = parse_vendor_definition(toml_text).expect_err("unknown id");
506        assert!(matches!(err, VendorError::UnknownVendorId { .. }));
507    }
508
509    #[test]
510    fn signals_by_source_groups_correctly() {
511        let def = VendorDefinition {
512            id: VendorId::DataDome,
513            display_name: "x".to_string(),
514            description: String::new(),
515            tier: 1,
516            signals: vec![
517                VendorSignal {
518                    pattern: "a".to_string(),
519                    source: EvidenceSource::Header,
520                    weight: 1,
521                },
522                VendorSignal {
523                    pattern: "b".to_string(),
524                    source: EvidenceSource::Header,
525                    weight: 2,
526                },
527                VendorSignal {
528                    pattern: "c".to_string(),
529                    source: EvidenceSource::Cookie,
530                    weight: 3,
531                },
532            ],
533        };
534        let grouped = def.signals_by_source();
535        assert_eq!(grouped.get(&EvidenceSource::Header).map(Vec::len), Some(2));
536        assert_eq!(grouped.get(&EvidenceSource::Cookie).map(Vec::len), Some(1));
537        assert_eq!(grouped.get(&EvidenceSource::BodyMarker).map(Vec::len), None);
538    }
539}