Skip to main content

stygian_charon/challenge_feedback/
memory.rs

1use std::num::NonZeroUsize;
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4use serde::{Deserialize, Serialize};
5
6use crate::cache::LruTtlStore;
7use crate::challenge_feedback::{ChallengeOutcome, EngineKey};
8use crate::types::TargetClass;
9
10/// Default TTL for the challenge memory: **10 minutes**.
11///
12/// This is short enough that one-off escalations decay quickly (so a
13/// single transient captcha does not poison the policy for hours)
14/// and long enough to span a typical scraping session that might
15/// retry the same domain several times before the operator decides
16/// to back off entirely.
17pub const DEFAULT_CHALLENGE_TTL: Duration = Duration::from_mins(10);
18
19/// Default capacity (in [`EngineKey`] entries) for the challenge
20/// memory. Conservative default — most workflows touch only a
21/// handful of distinct (engine, `target_class`, `tls_profile`) keys.
22#[allow(clippy::unwrap_used)]
23pub const DEFAULT_CHALLENGE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
24    Some(value) => value,
25    None => NonZeroUsize::MIN,
26};
27
28/// Default TTL for the system clock fallback when wall-clock time is
29/// unavailable. The value is small enough that a zero-second
30/// `recorded_at_unix_secs` is distinguishable from a real timestamp
31/// while still being a valid serialisation.
32const ZERO_FALLBACK_UNIX_SECS: u64 = 0;
33
34/// Build a stable cache key for the challenge memory entry keyed
35/// by [`EngineKey`].
36///
37/// The wire shape is
38/// `charon:challenge:engine[/version][+tls_profile]:target_class`
39/// so it round-trips with [`EngineKey`]'s `Display` impl and never
40/// collides with `charon:pow:...` (T93) or `charon:token_nonce:...`
41/// (T91) entries on a shared backing primitive.
42///
43/// # Example
44///
45/// ```
46/// use stygian_charon::challenge_feedback::{engine_memory_key, EngineKey};
47/// use stygian_charon::types::TargetClass;
48/// use stygian_charon::vendor_classifier::VendorId;
49///
50/// let key = EngineKey {
51///     engine: VendorId::Cloudflare,
52///     version: None,
53///     target_class: TargetClass::Api,
54///     tls_profile: None,
55/// };
56/// let wire = engine_memory_key(&key);
57/// assert!(wire.starts_with("charon:challenge:cloudflare:api"));
58/// ```
59#[must_use]
60pub fn engine_memory_key(key: &EngineKey) -> String {
61    format!("charon:challenge:{key}")
62}
63
64/// One entry in the challenge memory.
65///
66/// An entry represents the **last observed** outcome for a single
67/// [`EngineKey`], along with a count of how many times the runner
68/// has recorded an outcome for that key (capped at `u32::MAX` for
69/// monotonic counters) and the **last URL** the runner saw the
70/// outcome on (kept as a secondary debugging index only — the
71/// primary key is the engine, not the URL). The TTL is owned by
72/// the LRU+TTL store backing the [`ChallengeMemory`] — once the
73/// LRU entry expires, the whole entry is dropped and the runner
74/// falls back to the unadjusted risk score.
75///
76/// # Example
77///
78/// ```
79/// use stygian_charon::challenge_feedback::{ChallengeMemoryEntry, ChallengeOutcome, EngineKey};
80/// use stygian_charon::types::TargetClass;
81/// use stygian_charon::vendor_classifier::VendorId;
82///
83/// let entry = ChallengeMemoryEntry {
84///     key: EngineKey {
85///         engine: VendorId::Cloudflare,
86///         version: None,
87///         target_class: TargetClass::ContentSite,
88///         tls_profile: None,
89///     },
90///     last_observed_url: Some("https://example.com/path".to_string()),
91///     last_outcome: ChallengeOutcome::HardChallenge,
92///     observation_count: 1,
93///     recorded_at_unix_secs: 1_700_000_000,
94/// };
95/// assert_eq!(entry.risk_delta(), ChallengeOutcome::HardChallenge.risk_delta());
96/// ```
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ChallengeMemoryEntry {
99    /// Primary identity of this entry — the engine, target class,
100    /// version, and TLS profile the entry was recorded under.
101    pub key: EngineKey,
102    /// Optional last URL the outcome was observed on. Kept for
103    /// debugging ("where did we last see this engine?") — it is
104    /// NOT a primary key. Two different URLs on the same engine
105    /// share the same memory entry.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub last_observed_url: Option<String>,
108    /// Most recently recorded outcome for this key.
109    pub last_outcome: ChallengeOutcome,
110    /// Number of outcomes the runner has recorded for this key
111    /// (saturating on overflow).
112    pub observation_count: u32,
113    /// Unix epoch seconds when the entry was last updated.
114    pub recorded_at_unix_secs: u64,
115}
116
117impl ChallengeMemoryEntry {
118    /// Risk-score contribution this entry would add to the next
119    /// policy. Delegates to
120    /// [`ChallengeOutcome::risk_delta`][crate::challenge_feedback::ChallengeOutcome::risk_delta]
121    /// and is therefore bounded by
122    /// [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA].
123    #[must_use]
124    pub const fn risk_delta(&self) -> f64 {
125        self.last_outcome.risk_delta()
126    }
127
128    /// Convenience accessor for the entry's target class.
129    /// Mirrors [`EngineKey::target_class`] so callers do not have
130    /// to reach through `entry.key.target_class`.
131    #[must_use]
132    pub const fn target_class(&self) -> TargetClass {
133        self.key.target_class
134    }
135
136    /// Convenience accessor for the entry's engine. Mirrors
137    /// [`EngineKey::engine`] so callers do not have to reach
138    /// through `entry.key.engine`.
139    #[must_use]
140    pub const fn engine(&self) -> crate::vendor_classifier::VendorId {
141        self.key.engine
142    }
143}
144
145/// Capacity-bounded LRU+TTL store of [`ChallengeMemoryEntry`]s
146/// keyed by [`EngineKey`].
147///
148/// The store reuses the same LRU+TTL primitive the investigation
149/// cache and the `PoW` / token-nonce stores use (the shared
150/// `crate::cache::LruTtlStore` type — not re-exported). That keeps
151/// eviction + expiry semantics consistent across every short-horizon
152/// store in the crate and satisfies the "no new cache store" rule.
153///
154/// ## Why engine-keyed?
155///
156/// The primary key is the **engine** (Akamai, Cloudflare, `DataDome`,
157/// …), **not** the URL. A self-healing patch recorded against one
158/// URL on one engine heals every URL on that engine — a captcha
159/// workaround learned on `example.com/cloudflare/page1` is
160/// immediately applied to `example.com/cloudflare/page2` and to
161/// every other Cloudflare-fronted URL the runner sees. The URL
162/// is kept on each entry only as a secondary debugging index
163/// (see [`ChallengeMemoryEntry::last_observed_url`]).
164///
165/// # Example
166///
167/// ```
168/// use stygian_charon::challenge_feedback::{ChallengeMemory, ChallengeOutcome, EngineKey};
169/// use stygian_charon::types::TargetClass;
170/// use stygian_charon::vendor_classifier::VendorId;
171/// use std::num::NonZeroUsize;
172/// use std::time::Duration;
173///
174/// let memory =
175///     ChallengeMemory::new(NonZeroUsize::new(8).expect("non-zero"), Duration::from_mins(5));
176/// let key = EngineKey {
177///     engine: VendorId::Cloudflare,
178///     version: None,
179///     target_class: TargetClass::ContentSite,
180///     tls_profile: None,
181/// };
182/// memory.record(&key, Some("https://example.com/a"), ChallengeOutcome::Captcha);
183/// let entry = memory.lookup(&key).expect("entry");
184/// assert_eq!(entry.last_outcome, ChallengeOutcome::Captcha);
185/// assert_eq!(entry.observation_count, 1);
186/// ```
187pub struct ChallengeMemory {
188    store: LruTtlStore<ChallengeMemoryEntry>,
189}
190
191impl ChallengeMemory {
192    /// Create a new challenge memory with explicit capacity and TTL.
193    #[must_use]
194    pub fn new(capacity: NonZeroUsize, ttl: Duration) -> Self {
195        Self {
196            store: LruTtlStore::new(capacity, ttl),
197        }
198    }
199
200    /// Create a new challenge memory with
201    /// [`DEFAULT_CHALLENGE_CAPACITY`] and [`DEFAULT_CHALLENGE_TTL`].
202    #[must_use]
203    pub fn with_default_ttl(capacity: NonZeroUsize) -> Self {
204        Self::new(capacity, DEFAULT_CHALLENGE_TTL)
205    }
206
207    /// Capacity-bounded [`ChallengeMemory`] with the default
208    /// capacity and TTL.
209    #[must_use]
210    pub fn with_defaults() -> Self {
211        Self::new(DEFAULT_CHALLENGE_CAPACITY, DEFAULT_CHALLENGE_TTL)
212    }
213
214    /// Record a challenge outcome for an [`EngineKey`] and
215    /// optionally the URL the outcome was observed on. The URL is
216    /// stored only as a secondary debugging index — the primary
217    /// key is the engine.
218    ///
219    /// The read-modify-write sequence (peek current observation
220    /// count → build new entry → put) is **atomic** under
221    /// concurrency: two simultaneous `record` calls always observe
222    /// each other's prior increments. The underlying LRU+TTL store
223    /// exposes a `mutate(key, f)` primitive that holds the mutex
224    /// across the read-modify-write — see the
225    /// `stygian_charon::cache` module for the implementation.
226    ///
227    /// Expired entries start a fresh observation at count=1 (the
228    /// underlying `mutate` evicts expired entries first).
229    ///
230    /// # Example
231    ///
232    /// ```
233    /// use stygian_charon::challenge_feedback::{ChallengeMemory, ChallengeOutcome, EngineKey};
234    /// use stygian_charon::types::TargetClass;
235    /// use stygian_charon::vendor_classifier::VendorId;
236    ///
237    /// let memory = ChallengeMemory::with_defaults();
238    /// let key = EngineKey {
239    ///     engine: VendorId::Cloudflare,
240    ///     version: None,
241    ///     target_class: TargetClass::Api,
242    ///     tls_profile: None,
243    /// };
244    /// memory.record(&key, None, ChallengeOutcome::Pass);
245    /// let entry = memory.lookup(&key).expect("entry");
246    /// assert_eq!(entry.last_outcome, ChallengeOutcome::Pass);
247    /// assert_eq!(entry.observation_count, 1);
248    /// ```
249    pub fn record(&self, key: &EngineKey, observed_url: Option<&str>, outcome: ChallengeOutcome) {
250        let cache_key = engine_memory_key(key);
251        let entry_key = key.clone();
252        let observed_url_owned = observed_url.map(str::to_string);
253        self.store.mutate(cache_key, |existing| {
254            let next_count = existing.map_or(1, |prev| prev.observation_count.saturating_add(1));
255            ChallengeMemoryEntry {
256                key: entry_key,
257                last_observed_url: observed_url_owned,
258                last_outcome: outcome,
259                observation_count: next_count,
260                recorded_at_unix_secs: current_unix_secs(),
261            }
262        });
263    }
264
265    /// Look up the current entry for an [`EngineKey`]. Returns
266    /// `None` if the key is absent or has expired.
267    ///
268    /// # Example
269    ///
270    /// ```
271    /// use stygian_charon::challenge_feedback::{ChallengeMemory, EngineKey};
272    /// use stygian_charon::types::TargetClass;
273    /// use stygian_charon::vendor_classifier::VendorId;
274    ///
275    /// let memory = ChallengeMemory::with_defaults();
276    /// let key = EngineKey {
277    ///     engine: VendorId::DataDome,
278    ///     version: None,
279    ///     target_class: TargetClass::HighSecurity,
280    ///     tls_profile: None,
281    /// };
282    /// assert!(memory.lookup(&key).is_none());
283    /// ```
284    #[must_use]
285    pub fn lookup(&self, key: &EngineKey) -> Option<ChallengeMemoryEntry> {
286        self.store.get(&engine_memory_key(key))
287    }
288
289    /// Number of entries currently retained.
290    #[must_use]
291    pub fn len(&self) -> usize {
292        self.store.len()
293    }
294
295    /// `true` if the memory has zero entries.
296    #[must_use]
297    pub fn is_empty(&self) -> bool {
298        self.store.is_empty()
299    }
300
301    /// Remove all entries.
302    pub fn clear(&self) {
303        self.store.clear();
304    }
305
306    /// Invalidate a single [`EngineKey`].
307    pub fn invalidate(&self, key: &EngineKey) {
308        self.store.invalidate(&engine_memory_key(key));
309    }
310}
311
312fn current_unix_secs() -> u64 {
313    SystemTime::now()
314        .duration_since(UNIX_EPOCH)
315        .map_or(ZERO_FALLBACK_UNIX_SECS, |duration| duration.as_secs())
316}
317
318#[cfg(test)]
319#[allow(
320    clippy::unwrap_used,
321    clippy::expect_used,
322    clippy::panic,
323    clippy::indexing_slicing
324)]
325mod tests {
326    use super::*;
327    use std::thread;
328
329    fn cf_api() -> EngineKey {
330        EngineKey {
331            engine: crate::vendor_classifier::VendorId::Cloudflare,
332            version: None,
333            target_class: TargetClass::Api,
334            tls_profile: None,
335        }
336    }
337
338    fn cf_content() -> EngineKey {
339        EngineKey {
340            target_class: TargetClass::ContentSite,
341            ..cf_api()
342        }
343    }
344
345    fn cf_api_tls_chrome136() -> EngineKey {
346        EngineKey {
347            tls_profile: Some("chrome136".to_string()),
348            ..cf_api()
349        }
350    }
351
352    #[test]
353    fn record_overwrites_last_outcome_and_increments_count() {
354        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
355        let key = cf_content();
356
357        memory.record(&key, None, ChallengeOutcome::Pass);
358        memory.record(&key, None, ChallengeOutcome::HardChallenge);
359        memory.record(&key, None, ChallengeOutcome::Captcha);
360
361        let entry = memory.lookup(&key).expect("entry present");
362        assert_eq!(entry.last_outcome, ChallengeOutcome::Captcha);
363        assert_eq!(entry.observation_count, 3);
364        assert_eq!(entry.key, key);
365    }
366
367    #[test]
368    fn entries_decay_after_ttl() {
369        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_millis(1));
370        memory.record(&cf_api(), None, ChallengeOutcome::Blocked);
371        thread::sleep(Duration::from_millis(5));
372        assert!(memory.lookup(&cf_api()).is_none());
373    }
374
375    #[test]
376    fn distinct_target_classes_keep_distinct_entries() {
377        let memory = ChallengeMemory::new(NonZeroUsize::new(8).unwrap(), Duration::from_mins(1));
378
379        memory.record(&cf_api(), None, ChallengeOutcome::Pass);
380        memory.record(&cf_content(), None, ChallengeOutcome::Captcha);
381
382        let api = memory.lookup(&cf_api()).unwrap();
383        let content = memory.lookup(&cf_content()).unwrap();
384
385        assert_eq!(api.last_outcome, ChallengeOutcome::Pass);
386        assert_eq!(content.last_outcome, ChallengeOutcome::Captcha);
387    }
388
389    #[test]
390    fn clear_drops_everything() {
391        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
392        memory.record(&cf_api(), None, ChallengeOutcome::Pass);
393        let other = EngineKey {
394            engine: crate::vendor_classifier::VendorId::Akamai,
395            ..cf_api()
396        };
397        memory.record(&other, None, ChallengeOutcome::Blocked);
398        assert_eq!(memory.len(), 2);
399        memory.clear();
400        assert!(memory.is_empty());
401    }
402
403    #[test]
404    fn risk_delta_uses_last_outcome() {
405        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
406        memory.record(&cf_api(), None, ChallengeOutcome::HardChallenge);
407        let entry = memory.lookup(&cf_api()).unwrap();
408        assert!((entry.risk_delta() - ChallengeOutcome::HardChallenge.risk_delta()).abs() < 1e-9);
409    }
410
411    #[test]
412    fn lru_capacity_is_respected() {
413        let memory = ChallengeMemory::new(NonZeroUsize::new(2).unwrap(), Duration::from_mins(1));
414        for (i, vendor) in [
415            crate::vendor_classifier::VendorId::Akamai,
416            crate::vendor_classifier::VendorId::Cloudflare,
417            crate::vendor_classifier::VendorId::DataDome,
418        ]
419        .into_iter()
420        .enumerate()
421        {
422            let key = EngineKey {
423                engine: vendor,
424                ..cf_api()
425            };
426            // Differentiate by target_class on the last iteration
427            // to avoid the same-key overwrite collapsing entries.
428            let key = if i == 2 {
429                EngineKey {
430                    target_class: TargetClass::HighSecurity,
431                    ..key
432                }
433            } else {
434                key
435            };
436            memory.record(&key, None, ChallengeOutcome::Pass);
437        }
438        assert!(memory.len() <= 2);
439    }
440
441    // ----------------------------------------------------------------
442    // T110 guard tests
443    // ----------------------------------------------------------------
444
445    /// Guard test (T110): a self-healing patch recorded against
446    /// URL A on engine E propagates to URL B on engine E. The URL
447    /// is not the key; the engine is the key.
448    #[test]
449    fn same_engine_different_url_propagates_patch() {
450        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
451        let key = cf_api();
452
453        memory.record(
454            &key,
455            Some("https://example.com/cloudflare/page1"),
456            ChallengeOutcome::Captcha,
457        );
458
459        // Re-read with a *different* URL — same engine, same
460        // target class, no TLS profile. The patch must propagate.
461        let entry = memory.lookup(&key).expect("entry present");
462        assert_eq!(
463            entry.last_outcome,
464            ChallengeOutcome::Captcha,
465            "captcha recorded on URL A must heal the engine for URL B too"
466        );
467        assert_eq!(
468            entry.observation_count, 1,
469            "observation_count must reflect the single record (URLs are not keys)"
470        );
471        assert_eq!(
472            entry.last_observed_url.as_deref(),
473            Some("https://example.com/cloudflare/page1"),
474            "last_observed_url records the most-recent URL we saw the engine on"
475        );
476
477        // A subsequent record with a different URL updates the
478        // observed_url but keeps the same engine entry.
479        memory.record(
480            &key,
481            Some("https://example.com/cloudflare/page2"),
482            ChallengeOutcome::Pass,
483        );
484        let entry = memory.lookup(&key).expect("entry present");
485        assert_eq!(entry.observation_count, 2);
486        assert_eq!(
487            entry.last_observed_url.as_deref(),
488            Some("https://example.com/cloudflare/page2")
489        );
490    }
491
492    /// Guard test (T110): two `EngineKey`s differing only by
493    /// `target_class` keep separate memory. The `target_class`
494    /// field participates in the key.
495    #[test]
496    fn same_engine_different_target_class_keeps_separate_memory() {
497        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
498        let api = cf_api();
499        let content = cf_content();
500
501        memory.record(&api, None, ChallengeOutcome::Pass);
502        memory.record(&content, None, ChallengeOutcome::Captcha);
503
504        assert_eq!(
505            memory.lookup(&api).unwrap().last_outcome,
506            ChallengeOutcome::Pass
507        );
508        assert_eq!(
509            memory.lookup(&content).unwrap().last_outcome,
510            ChallengeOutcome::Captcha
511        );
512        assert_ne!(
513            memory.lookup(&api),
514            memory.lookup(&content),
515            "entries differing only by target_class must be distinct"
516        );
517    }
518
519    /// Guard test (T110): two `EngineKey`s differing only by
520    /// `tls_profile` keep separate memory. Encoding the wrong
521    /// combination is structurally impossible.
522    #[test]
523    fn same_engine_different_tls_profile_keeps_separate_memory() {
524        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
525        let baseline = cf_api();
526        let tls = cf_api_tls_chrome136();
527
528        memory.record(&baseline, None, ChallengeOutcome::Pass);
529        memory.record(&tls, None, ChallengeOutcome::HardChallenge);
530
531        let base_entry = memory.lookup(&baseline).expect("baseline entry");
532        let tls_entry = memory.lookup(&tls).expect("tls entry");
533        assert_eq!(base_entry.last_outcome, ChallengeOutcome::Pass);
534        assert_eq!(tls_entry.last_outcome, ChallengeOutcome::HardChallenge);
535        assert_ne!(base_entry.key, tls_entry.key);
536        assert_eq!(memory.len(), 2, "two distinct keys => two entries");
537    }
538
539    /// Guard test (T110): `EngineKey` round-trips through both
540    /// `Display`/`FromStr` and `Serialize`/`Deserialize`, and
541    /// the `engine_memory_key` wire format round-trips with
542    /// `EngineKey`'s `Display` impl for every supported field shape.
543    #[test]
544    fn engine_key_round_trips_through_display_fromstr_and_serde() {
545        let samples = [
546            EngineKey {
547                engine: crate::vendor_classifier::VendorId::Cloudflare,
548                version: None,
549                target_class: TargetClass::Api,
550                tls_profile: None,
551            },
552            EngineKey {
553                engine: crate::vendor_classifier::VendorId::Akamai,
554                version: Some("bot-manager-v3".to_string()),
555                target_class: TargetClass::HighSecurity,
556                tls_profile: None,
557            },
558            EngineKey {
559                engine: crate::vendor_classifier::VendorId::DataDome,
560                version: None,
561                target_class: TargetClass::ContentSite,
562                tls_profile: Some("firefox130".to_string()),
563            },
564            EngineKey {
565                engine: crate::vendor_classifier::VendorId::PerimeterX,
566                version: Some("human-v1".to_string()),
567                target_class: TargetClass::HighSecurity,
568                tls_profile: Some("chrome136".to_string()),
569            },
570        ];
571
572        for key in &samples {
573            let wire = engine_memory_key(key);
574            // The wire form embeds EngineKey::Display.
575            assert!(
576                wire.starts_with("charon:challenge:"),
577                "namespace prefix must be stable (got {wire})"
578            );
579
580            // Display <-> FromStr round-trip.
581            let rendered = key.to_string();
582            let parsed: EngineKey = rendered.parse().expect("FromStr round-trip");
583            assert_eq!(&parsed, key, "Display <-> FromStr round-trip failed");
584
585            // Serde <-> Serde round-trip via JSON.
586            let json = serde_json::to_string(key).expect("Serialize");
587            let de: EngineKey = serde_json::from_str(&json).expect("Deserialize");
588            assert_eq!(&de, key, "JSON round-trip failed for {key:?}");
589        }
590    }
591}