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}