Skip to main content

stygian_charon/challenge_feedback/
key.rs

1//! Engine-keyed memory identity (T110).
2//!
3//! The [`EngineKey`] is the **durable identity** of a scraping
4//! target: the anti-bot engine family, its version, the target
5//! class, and the TLS profile used to talk to it. It deliberately
6//! does **not** carry the URL — a self-healing patch recorded
7//! against one URL on one engine should heal every URL on that
8//! engine, not just the URL it was discovered on.
9//!
10//! The principle being encoded is the platform-keyed-memory
11//! instinct from the source guide: *"the durable asset is not the
12//! record you extracted, it is the route you learned to it."* A
13//! URL is volatile (it rotates, it expires, it points at one
14//! record among many); the engine is durable (it is the same vendor
15//! behind every URL on the same platform).
16//!
17//! All four fields participate in the [`EngineKey::hash`] /
18//! [`EngineKey::cmp`] equivalence so two entries recorded under
19//! different `version` or `tls_profile` values are **never** the
20//! same key. The four guard tests in the parent `memory` module
21//! exercise this property — re-keying a vendor version or changing
22//! the TLS profile deliberately produces a fresh memory slot.
23//!
24//! # Wire format
25//!
26//! [`EngineKey`] implements `Display`, `FromStr` (via
27//! `TryFrom<&str>`), and `serde::Serialize` /
28//! `serde::Deserialize`. The wire form is
29//! `engine[/version][+tls_profile]:target_class`, e.g.:
30//!
31//! - `cloudflare:api` — bare engine + target class
32//! - `cloudflare/bot-manager-v3:api` — with vendor version
33//! - `cloudflare+chrome136:api` — with TLS profile
34//! - `cloudflare/bot-manager-v3+chrome136:api` — fully specified
35//!
36//! `Display` round-trips through `TryFrom<&str>` and the JSON
37//! form is a 4-field object. The `Default` value is the
38//! `VendorId::Unknown` engine at `TargetClass::Unknown` with no
39//! version and no TLS profile.
40
41use std::fmt::{self, Display, Formatter};
42use std::str::FromStr;
43
44use serde::{Deserialize, Serialize};
45
46use crate::types::TargetClass;
47use crate::vendor_classifier::VendorId;
48
49/// Error returned when an [`EngineKey`] cannot be parsed from a
50/// string slice.
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52pub enum EngineKeyParseError {
53    /// The supplied string was empty.
54    #[error("engine key is empty")]
55    Empty,
56    /// The string was missing the `:` separator that delimits the
57    /// engine (and optional `version` / `tls_profile`) from the
58    /// target class.
59    #[error("engine key is missing ':' separator between engine and target class")]
60    MissingTargetClassSeparator,
61    /// The engine component did not match any [`VendorId`] label.
62    #[error("unknown engine label: {0}")]
63    UnknownEngine(String),
64    /// The target class component did not match any
65    /// [`TargetClass`] label.
66    #[error("unknown target class label: {0}")]
67    UnknownTargetClass(String),
68    /// The TLS profile or version component was empty after the
69    /// `+` or `/` separator.
70    #[error("engine key has empty {0} component")]
71    EmptyModifier(&'static str),
72}
73
74/// Durable identity of a scraping target's anti-bot engine.
75///
76/// Two `EngineKey` values are equal iff **every** field is equal
77/// — including the optional `version` and `tls_profile`. This is
78/// deliberate: an Akamai Bot Manager v3 patch must not silently
79/// apply to a v4 deployment, and a Chrome-136 TLS-profile patch
80/// must not silently apply to a Firefox-130 profile.
81///
82/// The field layout matches the T110 spec: `engine` is the
83/// anti-bot engine family, `version` is the vendor's revision
84/// (e.g. `bot-manager-v3`), `target_class` is the site's posture,
85/// and `tls_profile` is the client-side TLS fingerprint (e.g.
86/// `chrome136`).
87///
88/// # Example
89///
90/// ```
91/// use stygian_charon::challenge_feedback::EngineKey;
92/// use stygian_charon::types::TargetClass;
93/// use stygian_charon::vendor_classifier::VendorId;
94///
95/// let key = EngineKey {
96///     engine: VendorId::Cloudflare,
97///     version: Some("bot-manager-v3".to_string()),
98///     target_class: TargetClass::Api,
99///     tls_profile: Some("chrome136".to_string()),
100/// };
101/// assert_eq!(key.engine, VendorId::Cloudflare);
102/// assert_eq!(key.version.as_deref(), Some("bot-manager-v3"));
103/// ```
104#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
105pub struct EngineKey {
106    /// Anti-bot engine family. Use `VendorId::Unknown` only when
107    /// no engine could be classified.
108    pub engine: VendorId,
109    /// Optional vendor version (e.g. `"bot-manager-v3"`).
110    /// `None` means the version is unknown or unversioned.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub version: Option<String>,
113    /// Target class (api / `content_site` / `high_security` / unknown).
114    pub target_class: TargetClass,
115    /// Optional client TLS profile (e.g. `"chrome136"`).
116    /// `None` means the TLS profile was not recorded.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub tls_profile: Option<String>,
119}
120
121impl EngineKey {
122    /// Engine component of the key, used for display and stable
123    /// hashing. Returns [`VendorId::label`].
124    #[must_use]
125    pub const fn engine_label(&self) -> &'static str {
126        self.engine.label()
127    }
128
129    /// Validate the key has no empty optional fields. Returns
130    /// `Ok(())` for well-formed keys; otherwise a
131    /// [`EngineKeyParseError::EmptyModifier`].
132    ///
133    /// This is primarily a helper for `FromStr` but is also
134    /// useful when constructing keys from user-supplied config.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`EngineKeyParseError::EmptyModifier`] if either
139    /// optional component is an empty string.
140    pub fn validate(&self) -> Result<(), EngineKeyParseError> {
141        if let Some(version) = self.version.as_deref()
142            && version.is_empty()
143        {
144            return Err(EngineKeyParseError::EmptyModifier("version"));
145        }
146        if let Some(tls_profile) = self.tls_profile.as_deref()
147            && tls_profile.is_empty()
148        {
149            return Err(EngineKeyParseError::EmptyModifier("tls_profile"));
150        }
151        Ok(())
152    }
153}
154
155impl Display for EngineKey {
156    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
157        write!(f, "{}", self.engine.label())?;
158        if let Some(version) = self.version.as_deref() {
159            write!(f, "/{version}")?;
160        }
161        if let Some(tls_profile) = self.tls_profile.as_deref() {
162            write!(f, "+{tls_profile}")?;
163        }
164        let target_class = match self.target_class {
165            TargetClass::Api => "api",
166            TargetClass::ContentSite => "content_site",
167            TargetClass::HighSecurity => "high_security",
168            TargetClass::Unknown => "unknown",
169        };
170        write!(f, ":{target_class}")
171    }
172}
173
174impl FromStr for EngineKey {
175    type Err = EngineKeyParseError;
176
177    fn from_str(s: &str) -> Result<Self, Self::Err> {
178        if s.is_empty() {
179            return Err(EngineKeyParseError::Empty);
180        }
181
182        let (head, target_class_str) = s
183            .rsplit_once(':')
184            .ok_or(EngineKeyParseError::MissingTargetClassSeparator)?;
185        let target_class = match target_class_str {
186            "api" => TargetClass::Api,
187            "content_site" => TargetClass::ContentSite,
188            "high_security" => TargetClass::HighSecurity,
189            "unknown" => TargetClass::Unknown,
190            other => return Err(EngineKeyParseError::UnknownTargetClass(other.to_string())),
191        };
192
193        // Head: `engine[/version][+tls_profile]`
194        let mut version: Option<String> = None;
195        let mut tls_profile: Option<String> = None;
196        let engine_str;
197
198        if let Some((pre_tls, tls)) = head.split_once('+') {
199            if tls.is_empty() {
200                return Err(EngineKeyParseError::EmptyModifier("tls_profile"));
201            }
202            tls_profile = Some(tls.to_string());
203            if let Some((pre_ver, ver)) = pre_tls.split_once('/') {
204                if ver.is_empty() {
205                    return Err(EngineKeyParseError::EmptyModifier("version"));
206                }
207                version = Some(ver.to_string());
208                engine_str = pre_ver;
209            } else {
210                engine_str = pre_tls;
211            }
212        } else if let Some((pre_ver, ver)) = head.split_once('/') {
213            if ver.is_empty() {
214                return Err(EngineKeyParseError::EmptyModifier("version"));
215            }
216            version = Some(ver.to_string());
217            engine_str = pre_ver;
218        } else {
219            engine_str = head;
220        }
221
222        let engine = VendorId::from_label(engine_str)
223            .ok_or_else(|| EngineKeyParseError::UnknownEngine(engine_str.to_string()))?;
224
225        Ok(Self {
226            engine,
227            version,
228            target_class,
229            tls_profile,
230        })
231    }
232}
233
234#[cfg(test)]
235#[allow(
236    clippy::unwrap_used,
237    clippy::expect_used,
238    clippy::panic,
239    clippy::indexing_slicing
240)]
241mod tests {
242    use super::*;
243
244    fn cloudflare_api() -> EngineKey {
245        EngineKey {
246            engine: VendorId::Cloudflare,
247            version: None,
248            target_class: TargetClass::Api,
249            tls_profile: None,
250        }
251    }
252
253    #[test]
254    fn display_round_trips_through_from_str() {
255        for key in [
256            cloudflare_api(),
257            EngineKey {
258                engine: VendorId::Akamai,
259                version: Some("bot-manager-v3".to_string()),
260                target_class: TargetClass::HighSecurity,
261                tls_profile: None,
262            },
263            EngineKey {
264                engine: VendorId::Cloudflare,
265                version: None,
266                target_class: TargetClass::ContentSite,
267                tls_profile: Some("chrome136".to_string()),
268            },
269            EngineKey {
270                engine: VendorId::DataDome,
271                version: Some("v2".to_string()),
272                target_class: TargetClass::Api,
273                tls_profile: Some("firefox130".to_string()),
274            },
275        ] {
276            let rendered = key.to_string();
277            let parsed: EngineKey = rendered.parse().expect("round-trip parse");
278            assert_eq!(parsed, key, "round-trip mismatch for {rendered}");
279        }
280    }
281
282    #[test]
283    fn from_str_rejects_empty_input() {
284        assert_eq!(
285            EngineKey::from_str(""),
286            Err(EngineKeyParseError::Empty),
287            "empty input must be rejected"
288        );
289    }
290
291    #[test]
292    fn from_str_rejects_missing_target_class_separator() {
293        assert_eq!(
294            EngineKey::from_str("cloudflare"),
295            Err(EngineKeyParseError::MissingTargetClassSeparator),
296            "missing ':' separator must be rejected"
297        );
298    }
299
300    #[test]
301    fn from_str_rejects_unknown_engine_label() {
302        assert_eq!(
303            EngineKey::from_str("not_a_vendor:api"),
304            Err(EngineKeyParseError::UnknownEngine(
305                "not_a_vendor".to_string()
306            )),
307            "unknown engine label must be rejected"
308        );
309    }
310
311    #[test]
312    fn from_str_rejects_unknown_target_class_label() {
313        assert_eq!(
314            EngineKey::from_str("cloudflare:not_a_class"),
315            Err(EngineKeyParseError::UnknownTargetClass(
316                "not_a_class".to_string()
317            )),
318            "unknown target class must be rejected"
319        );
320    }
321
322    #[test]
323    fn from_str_rejects_empty_modifier_components() {
324        assert_eq!(
325            EngineKey::from_str("cloudflare/:api"),
326            Err(EngineKeyParseError::EmptyModifier("version")),
327            "empty version must be rejected"
328        );
329        assert_eq!(
330            EngineKey::from_str("cloudflare+:api"),
331            Err(EngineKeyParseError::EmptyModifier("tls_profile")),
332            "empty tls_profile must be rejected"
333        );
334    }
335
336    #[test]
337    fn keys_differing_only_by_target_class_are_not_equal() {
338        let a = cloudflare_api();
339        let b = EngineKey {
340            target_class: TargetClass::ContentSite,
341            ..a.clone()
342        };
343        assert_ne!(a, b, "target_class must participate in equality");
344    }
345
346    #[test]
347    fn keys_differing_only_by_tls_profile_are_not_equal() {
348        let a = cloudflare_api();
349        let b = EngineKey {
350            tls_profile: Some("chrome136".to_string()),
351            ..a.clone()
352        };
353        assert_ne!(a, b, "tls_profile must participate in equality");
354    }
355
356    #[test]
357    fn keys_differing_only_by_version_are_not_equal() {
358        let a = EngineKey {
359            version: Some("v3".to_string()),
360            ..cloudflare_api()
361        };
362        let b = EngineKey {
363            version: Some("v4".to_string()),
364            ..a.clone()
365        };
366        assert_ne!(a, b, "version must participate in equality");
367    }
368
369    #[test]
370    fn serde_json_round_trips_through_engine_key() {
371        let key = EngineKey {
372            engine: VendorId::PerimeterX,
373            version: Some("human-v1".to_string()),
374            target_class: TargetClass::HighSecurity,
375            tls_profile: Some("chrome136".to_string()),
376        };
377        let json = serde_json::to_string(&key).expect("serialize");
378        let parsed: EngineKey = serde_json::from_str(&json).expect("deserialize");
379        assert_eq!(parsed, key, "JSON round-trip mismatch");
380    }
381
382    #[test]
383    fn from_str_uses_rsplit_once_to_locate_target_class() {
384        // Multiple `:` characters in the input: only the last one is
385        // the engine/target-class separator (`rsplit_once` semantics).
386        // The trailing "extra" segment is what gets parsed as the
387        // target class, so it must surface as `UnknownTargetClass`
388        // (no VendorId label contains a `:`).
389        match EngineKey::from_str("cloudflare:api:extra") {
390            Err(EngineKeyParseError::UnknownTargetClass(s)) => assert_eq!(s, "extra"),
391            other => panic!(
392                "extra colons must surface as UnknownTargetClass on the suffix, got {other:?}"
393            ),
394        }
395
396        // Sanity: a single trailing colon leaves the target class
397        // empty, which is a parse error.
398        assert_eq!(
399            EngineKey::from_str("cloudflare:"),
400            Err(EngineKeyParseError::UnknownTargetClass(String::new()))
401        );
402    }
403
404    #[test]
405    fn validate_rejects_empty_optional_components() {
406        let bad_version = EngineKey {
407            version: Some(String::new()),
408            ..cloudflare_api()
409        };
410        assert_eq!(
411            bad_version.validate(),
412            Err(EngineKeyParseError::EmptyModifier("version"))
413        );
414
415        let bad_tls = EngineKey {
416            tls_profile: Some(String::new()),
417            ..cloudflare_api()
418        };
419        assert_eq!(
420            bad_tls.validate(),
421            Err(EngineKeyParseError::EmptyModifier("tls_profile"))
422        );
423
424        assert_eq!(cloudflare_api().validate(), Ok(()));
425    }
426}