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}