stygian_charon/token_lifecycle/validator.rs
1//! Token validator (T91).
2//!
3//! The [`TokenValidator`] consumes a [`TokenContract`] and a
4//! present-time clock, evaluates the four lifecycle invariants
5//! (TTL, single-use, nonce match, session binding), and returns
6//! a [`ValidationOutcome`] the runner can act on. The
7//! validator integrates with the
8//! [`ChallengeMemory`][crate::challenge_feedback::ChallengeMemory]
9//! indirectly — nonce bookkeeping lives in
10//! [`NonceBook`][crate::token_lifecycle::NonceBook], a separate
11//! store that reuses the same LRU+TTL primitive the T83
12//! challenge memory uses (so eviction + expiry semantics stay
13//! consistent across both short-horizon stores).
14//!
15//! ## Validation flow
16//!
17//! 1. **TTL clamp**: if the contract's `ttl` exceeds the
18//! per-vendor `max_ttl`, the validator clamps the effective
19//! TTL to the per-vendor ceiling. This stops a contract
20//! factory from accidentally issuing overly-long-lived
21//! tokens.
22//! 2. **TTL check**: the validator computes the token's age at
23//! `now_unix_secs` and rejects tokens whose age is at or
24//! beyond the effective TTL. The reason carries the
25//! observed age and the TTL the contract was issued with.
26//! 3. **Nonce presence check**: if the vendor policy requires
27//! nonces (or the challenge class requires nonces), the
28//! validator rejects empty nonces with
29//! [`InvalidationKind::NonceMismatch`][crate::token_lifecycle::InvalidationKind::NonceMismatch].
30//! 4. **Single-use / replay check**: if the nonce is already
31//! present in the [`NonceBook`] with an observation count
32//! `>= 1`, the validator rejects the submission with
33//! [`InvalidationKind::NonceReplayed`][crate::token_lifecycle::InvalidationKind::NonceReplayed].
34//! Multi-use tokens (`single_use = false`) bypass the
35//! replay check.
36//! 5. **Session binding check**: if the vendor policy or
37//! challenge class requires session binding, the validator
38//! rejects submissions whose `session_id` does not match
39//! the contract's `bound_session`.
40//! 6. **Not-applicable check**: if the challenge class is not
41//! applicable for the vendor family (e.g. Cloudflare does
42//! not issue `proof-of-work` tokens), the validator rejects
43//! with
44//! [`InvalidationKind::NotApplicable`][crate::token_lifecycle::InvalidationKind::NotApplicable].
45//! 7. **Accept + mark consumed**: the validator records the
46//! nonce in the [`NonceBook`] (so the next submission is
47//! rejected with `NonceReplayed`) and returns
48//! [`ValidationOutcome::Ok`].
49
50use std::time::Duration;
51
52use crate::token_lifecycle::contract::{ChallengeClass, TokenContract};
53use crate::token_lifecycle::error::TokenLifecycleError;
54use crate::token_lifecycle::invalidation::{InvalidationKind, InvalidationReason};
55use crate::token_lifecycle::nonce::NonceBook;
56use crate::token_lifecycle::policy::TokenPolicyTable;
57use crate::vendor_classifier::VendorId;
58
59/// Outcome of a [`TokenValidator::validate`] call.
60///
61/// The validator always returns a [`ValidationOutcome`] — never
62/// a `Result` — so the caller can branch on the outcome
63/// without unwrapping. The error path embeds the structured
64/// [`InvalidationReason`] for diagnostic routing.
65///
66/// # Example
67///
68/// ```
69/// use stygian_charon::token_lifecycle::{
70/// ChallengeClass, TokenContract, TokenPolicyTable, TokenValidator,
71/// ValidationOutcome,
72/// };
73/// use stygian_charon::vendor_classifier::VendorId;
74/// use std::time::Duration;
75///
76/// let validator = TokenValidator::with_defaults(TokenPolicyTable::with_builtin_defaults());
77/// let contract = TokenContract {
78/// token_id: "x".to_string(),
79/// issued_at_unix_secs: 0,
80/// ttl: Duration::from_mins(5),
81/// nonce: "n".to_string(),
82/// vendor_family: VendorId::Cloudflare,
83/// challenge_class: ChallengeClass::Interstitial,
84/// single_use: true,
85/// bound_session: None,
86/// description: String::new(),
87/// };
88/// let outcome = validator.validate(&contract, None, 60);
89/// assert!(matches!(outcome, ValidationOutcome::Ok { .. }));
90/// ```
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum ValidationOutcome {
93 /// Validator accepted the submission.
94 Ok {
95 /// Contract that was accepted (with the TTL the
96 /// validator actually applied — possibly clamped to
97 /// the per-vendor `max_ttl`).
98 contract: TokenContract,
99 /// `true` when the nonce was newly observed by this
100 /// submission (i.e. the validator marked it consumed).
101 /// `false` for multi-use tokens whose nonce was already
102 /// in the [`NonceBook`].
103 consumed: bool,
104 /// Effective TTL the validator applied after clamping.
105 effective_ttl: Duration,
106 },
107 /// Validator rejected the submission. The error carries
108 /// the structured [`InvalidationReason`] and a
109 /// human-readable message suitable for operator logs.
110 Rejected(TokenLifecycleError),
111}
112
113impl ValidationOutcome {
114 /// `true` when the validator accepted the submission.
115 ///
116 /// # Example
117 ///
118 /// ```
119 /// use stygian_charon::token_lifecycle::{ValidationOutcome};
120 ///
121 /// let outcome = ValidationOutcome::Ok {
122 /// contract: stygian_charon::token_lifecycle::TokenContract {
123 /// token_id: "x".to_string(),
124 /// issued_at_unix_secs: 0,
125 /// ttl: std::time::Duration::from_mins(1),
126 /// nonce: "n".to_string(),
127 /// vendor_family: stygian_charon::vendor_classifier::VendorId::Unknown,
128 /// challenge_class: stygian_charon::token_lifecycle::ChallengeClass::None,
129 /// single_use: false,
130 /// bound_session: None,
131 /// description: String::new(),
132 /// },
133 /// consumed: true,
134 /// effective_ttl: std::time::Duration::from_mins(1),
135 /// };
136 /// assert!(outcome.is_ok());
137 /// ```
138 #[must_use]
139 pub const fn is_ok(&self) -> bool {
140 matches!(self, Self::Ok { .. })
141 }
142
143 /// `true` when the validator rejected the submission.
144 #[must_use]
145 pub const fn is_rejected(&self) -> bool {
146 matches!(self, Self::Rejected(_))
147 }
148
149 /// Borrow the underlying error when [`Rejected`][Self::Rejected].
150 #[must_use]
151 pub const fn error(&self) -> Option<&TokenLifecycleError> {
152 match self {
153 Self::Rejected(err) => Some(err),
154 Self::Ok { .. } => None,
155 }
156 }
157
158 /// Invalidation kind when [`Rejected`][Self::Rejected].
159 #[must_use]
160 pub fn invalidation_kind(&self) -> Option<InvalidationKind> {
161 self.error().map(|e| e.reason.kind())
162 }
163}
164
165/// Token validator.
166///
167/// Holds the [`NonceBook`] (LRU+TTL nonce store) and the
168/// [`TokenPolicyTable`] (per-vendor policy lookup). The
169/// validator is `Send + Sync` so it can sit behind an `Arc`
170/// and be shared across threads and requests without locking.
171///
172/// # Example
173///
174/// ```
175/// use std::time::Duration;
176/// use stygian_charon::token_lifecycle::{
177/// ChallengeClass, TokenContract, TokenPolicyTable, TokenValidator,
178/// };
179/// use stygian_charon::vendor_classifier::VendorId;
180///
181/// let policy = TokenPolicyTable::with_builtin_defaults();
182/// let validator = TokenValidator::new(
183/// stygian_charon::token_lifecycle::NonceBook::with_defaults(),
184/// policy,
185/// );
186/// let contract = TokenContract {
187/// token_id: "x".to_string(),
188/// issued_at_unix_secs: 0,
189/// ttl: Duration::from_mins(5),
190/// nonce: "n".to_string(),
191/// vendor_family: VendorId::Unknown,
192/// challenge_class: ChallengeClass::None,
193/// single_use: false,
194/// bound_session: None,
195/// description: String::new(),
196/// };
197/// let outcome = validator.validate(&contract, None, 0);
198/// assert!(outcome.is_ok());
199/// ```
200pub struct TokenValidator {
201 nonce_book: NonceBook,
202 policy: TokenPolicyTable,
203}
204
205impl std::fmt::Debug for TokenValidator {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 f.debug_struct("TokenValidator")
208 .field("nonce_book", &self.nonce_book)
209 .field("policy_vendors", &self.policy.len())
210 .finish()
211 }
212}
213
214impl TokenValidator {
215 /// Build a validator with an explicit nonce book and
216 /// policy table.
217 ///
218 /// # Example
219 ///
220 /// ```
221 /// use std::num::NonZeroUsize;
222 /// use std::time::Duration;
223 /// use stygian_charon::token_lifecycle::{NonceBook, TokenPolicyTable, TokenValidator};
224 ///
225 /// let validator = TokenValidator::new(
226 /// NonceBook::new(NonZeroUsize::new(8).expect("non-zero"), Duration::from_mins(1)),
227 /// TokenPolicyTable::with_builtin_defaults(),
228 /// );
229 /// assert_eq!(validator.policy().len(), 11);
230 /// ```
231 #[must_use]
232 pub const fn new(nonce_book: NonceBook, policy: TokenPolicyTable) -> Self {
233 Self { nonce_book, policy }
234 }
235
236 /// Build a validator with the default
237 /// [`NonceBook::with_defaults()`][crate::token_lifecycle::NonceBook::with_defaults]
238 /// nonce book and the supplied policy table.
239 ///
240 /// # Example
241 ///
242 /// ```
243 /// use stygian_charon::token_lifecycle::{TokenPolicyTable, TokenValidator};
244 ///
245 /// let validator = TokenValidator::with_defaults(TokenPolicyTable::with_builtin_defaults());
246 /// assert!(validator.nonce_book().is_empty());
247 /// ```
248 #[must_use]
249 pub fn with_defaults(policy: TokenPolicyTable) -> Self {
250 Self::new(NonceBook::with_defaults(), policy)
251 }
252
253 /// Borrow the nonce book.
254 #[must_use]
255 pub const fn nonce_book(&self) -> &NonceBook {
256 &self.nonce_book
257 }
258
259 /// Borrow the policy table.
260 #[must_use]
261 pub const fn policy(&self) -> &TokenPolicyTable {
262 &self.policy
263 }
264
265 /// Validate a [`TokenContract`] against the supplied
266 /// `session_id` (when the submission carries one) and the
267 /// supplied `now_unix_secs` clock.
268 ///
269 /// On accept, the nonce is recorded in the [`NonceBook`]
270 /// (so the next submission is rejected as a replay) and the
271 /// outcome's `consumed` flag is `true`. On reject, the
272 /// outcome is [`ValidationOutcome::Rejected`] with the
273 /// structured [`InvalidationReason`] the runner can route
274 /// into the per-family audit log.
275 #[allow(clippy::too_many_lines)]
276 pub fn validate(
277 &self,
278 contract: &TokenContract,
279 session_id: Option<&str>,
280 now_unix_secs: u64,
281 ) -> ValidationOutcome {
282 let vendor_policy = self.policy.policy(contract.vendor_family);
283
284 // 1. Not-applicable check.
285 if let Some(reason) = Self::check_not_applicable(contract) {
286 return ValidationOutcome::Rejected(reason);
287 }
288
289 // 2. TTL clamp.
290 let effective_ttl = if contract.ttl > vendor_policy.max_ttl() {
291 vendor_policy.max_ttl()
292 } else {
293 contract.ttl
294 };
295 let age = contract.age_secs(now_unix_secs);
296
297 // 3. TTL check.
298 if age >= effective_ttl.as_secs() {
299 return ValidationOutcome::Rejected(TokenLifecycleError::new(
300 InvalidationReason::Expired {
301 vendor: contract.vendor_family,
302 challenge_class: contract.challenge_class,
303 age_secs: age,
304 ttl_secs: effective_ttl.as_secs(),
305 },
306 format!(
307 "{:?} token '{}' expired (age {}s >= ttl {}s)",
308 contract.vendor_family,
309 contract.token_id,
310 age,
311 effective_ttl.as_secs()
312 ),
313 ));
314 }
315
316 // 4. Nonce presence check.
317 let nonce_required =
318 vendor_policy.require_nonce() || contract.challenge_class.requires_nonce();
319 if nonce_required && contract.nonce.is_empty() {
320 return ValidationOutcome::Rejected(TokenLifecycleError::new(
321 InvalidationReason::NonceMismatch {
322 vendor: contract.vendor_family,
323 challenge_class: contract.challenge_class,
324 expected: "<required>".to_string(),
325 observed: String::new(),
326 },
327 format!(
328 "{:?} token '{}' missing nonce",
329 contract.vendor_family, contract.token_id
330 ),
331 ));
332 }
333
334 // 5. Single-use / replay check.
335 let effective_single_use = contract.single_use || vendor_policy.single_use();
336 if effective_single_use
337 && !contract.nonce.is_empty()
338 && let Some(observation) = self
339 .nonce_book
340 .lookup(contract.vendor_family, &contract.nonce)
341 {
342 return ValidationOutcome::Rejected(TokenLifecycleError::new(
343 InvalidationReason::NonceReplayed {
344 vendor: contract.vendor_family,
345 challenge_class: contract.challenge_class,
346 observation_count: observation.observation_count,
347 },
348 format!(
349 "{:?} token nonce '{}' replayed ({} observations)",
350 contract.vendor_family, contract.nonce, observation.observation_count
351 ),
352 ));
353 }
354
355 // 6. Session binding check.
356 let binding_required = vendor_policy.require_session_binding()
357 || contract.challenge_class.requires_session_binding();
358 if binding_required {
359 let expected = contract.bound_session.as_deref();
360 let observed = session_id;
361 let miss = match (expected, observed) {
362 (Some(e), Some(o)) => e != o,
363 (Some(_), None) => true,
364 (None, _) => false,
365 };
366 if miss {
367 return ValidationOutcome::Rejected(TokenLifecycleError::new(
368 InvalidationReason::SessionBindingMiss {
369 vendor: contract.vendor_family,
370 challenge_class: contract.challenge_class,
371 expected: contract.bound_session.clone(),
372 observed: session_id.map(str::to_string),
373 },
374 format!(
375 "{:?} token '{}' session binding miss (expected {:?}, observed {:?})",
376 contract.vendor_family,
377 contract.token_id,
378 contract.bound_session,
379 session_id
380 ),
381 ));
382 }
383 }
384
385 // 7. Accept + mark consumed.
386 if !contract.nonce.is_empty() {
387 self.nonce_book.record(
388 contract.vendor_family,
389 contract.challenge_class,
390 &contract.nonce,
391 );
392 }
393
394 let mut accepted = contract.clone();
395 accepted.ttl = effective_ttl;
396 ValidationOutcome::Ok {
397 contract: accepted,
398 consumed: effective_single_use && !contract.nonce.is_empty(),
399 effective_ttl,
400 }
401 }
402
403 /// `true` when the supplied `(vendor, challenge_class)`
404 /// pair is not a valid combination (e.g. Cloudflare does
405 /// not issue `PoW` tokens; `FingerprintCom` does not issue
406 /// captcha tokens).
407 fn check_not_applicable(contract: &TokenContract) -> Option<TokenLifecycleError> {
408 let applicable = is_applicable(contract.vendor_family, contract.challenge_class);
409 if applicable {
410 None
411 } else {
412 Some(TokenLifecycleError::new(
413 InvalidationReason::NotApplicable {
414 vendor: contract.vendor_family,
415 challenge_class: contract.challenge_class,
416 },
417 format!(
418 "{:?} does not issue {:?} tokens",
419 contract.vendor_family, contract.challenge_class
420 ),
421 ))
422 }
423 }
424}
425
426/// `true` when the supplied `(vendor, challenge_class)` pair is
427/// a valid combination. The matrix mirrors the
428/// [vendor policy table][crate::token_lifecycle#vendor-policy-table]:
429///
430/// - **Tier 2 vendors** (`DataDome`, `PerimeterX`, `Akamai`,
431/// `Imperva`, `ShapeSecurity`, `Kasada`) issue **every**
432/// challenge class except `None` (the cookie class).
433/// - **Tier 1 captcha providers** (`Hcaptcha`, `Recaptcha`)
434/// only issue [`ChallengeClass::Captcha`] and
435/// [`ChallengeClass::CookieRefresh`].
436/// - **`FingerprintCom`** only issues
437/// [`ChallengeClass::None`] (identification cookies) and
438/// [`ChallengeClass::CookieRefresh`].
439/// - **`Cloudflare`** issues [`ChallengeClass::Interstitial`]
440/// and [`ChallengeClass::Captcha`] plus
441/// [`ChallengeClass::None`] / `CookieRefresh`.
442/// - **`Unknown`** only issues the safe defaults
443/// (`None`, `CookieRefresh`, `Unknown`).
444const fn is_applicable(vendor: VendorId, challenge_class: ChallengeClass) -> bool {
445 use ChallengeClass as C;
446 use VendorId as V;
447 match vendor {
448 V::DataDome | V::PerimeterX | V::Akamai | V::Imperva | V::ShapeSecurity | V::Kasada => {
449 matches!(
450 challenge_class,
451 C::Interstitial
452 | C::Captcha
453 | C::ProofOfWork
454 | C::IntegrityCheck
455 | C::CookieRefresh
456 | C::Unknown
457 )
458 }
459 V::Hcaptcha | V::Recaptcha => {
460 matches!(challenge_class, C::Captcha | C::CookieRefresh | C::Unknown)
461 }
462 V::FingerprintCom => matches!(challenge_class, C::None | C::CookieRefresh | C::Unknown),
463 V::Cloudflare => matches!(
464 challenge_class,
465 C::None | C::Interstitial | C::CookieRefresh | C::Unknown
466 ),
467 V::Unknown => matches!(challenge_class, C::None | C::CookieRefresh | C::Unknown),
468 }
469}
470
471#[cfg(test)]
472#[allow(
473 clippy::unwrap_used,
474 clippy::expect_used,
475 clippy::panic,
476 clippy::indexing_slicing
477)]
478mod tests {
479 use super::*;
480
481 fn approx_eq(a: Duration, b: Duration) -> bool {
482 a == b
483 }
484
485 fn validator() -> TokenValidator {
486 TokenValidator::with_defaults(TokenPolicyTable::with_builtin_defaults())
487 }
488
489 fn contract(
490 vendor: VendorId,
491 class: ChallengeClass,
492 ttl: Duration,
493 nonce: &str,
494 single_use: bool,
495 bound_session: Option<&str>,
496 issued_at_unix_secs: u64,
497 ) -> TokenContract {
498 TokenContract {
499 token_id: format!("{}-token", vendor.label()),
500 issued_at_unix_secs,
501 ttl,
502 nonce: nonce.to_string(),
503 vendor_family: vendor,
504 challenge_class: class,
505 single_use,
506 bound_session: bound_session.map(str::to_string),
507 description: String::new(),
508 }
509 }
510
511 #[test]
512 fn accepts_fresh_cloudflare_interstitial() {
513 let v = validator();
514 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
515 let c = contract(
516 VendorId::Cloudflare,
517 ChallengeClass::Interstitial,
518 Duration::from_mins(30),
519 "nonce-1",
520 true,
521 None,
522 0,
523 );
524 let outcome = v.validate(&c, None, 60);
525 match outcome {
526 ValidationOutcome::Ok { consumed, .. } => assert!(consumed),
527 ValidationOutcome::Rejected(err) => panic!("unexpected reject: {err:?}"),
528 }
529 }
530
531 #[test]
532 fn rejects_expired_cloudflare_interstitial() {
533 let v = validator();
534 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
535 let c = contract(
536 VendorId::Cloudflare,
537 ChallengeClass::Interstitial,
538 Duration::from_mins(30),
539 "nonce-2",
540 true,
541 None,
542 0,
543 );
544 // Submitted 31 minutes after issuance (Cloudflare max ttl = 45 minutes).
545 let outcome = v.validate(&c, None, 31 * 60);
546 match outcome {
547 ValidationOutcome::Rejected(err) => {
548 assert_eq!(
549 err.reason.kind(),
550 InvalidationKind::Expired,
551 "expected Expired, got {:?}",
552 err.reason
553 );
554 assert_eq!(err.reason.vendor_family(), VendorId::Cloudflare);
555 }
556 ValidationOutcome::Ok { .. } => panic!("expected reject"),
557 }
558 }
559
560 #[test]
561 fn rejects_single_use_replay() {
562 let v = validator();
563 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
564 let c = contract(
565 VendorId::DataDome,
566 ChallengeClass::Captcha,
567 Duration::from_mins(10),
568 "replay-n",
569 true,
570 None,
571 0,
572 );
573 let first = v.validate(&c, None, 60);
574 assert!(matches!(first, ValidationOutcome::Ok { .. }));
575 let second = v.validate(&c, None, 60);
576 match second {
577 ValidationOutcome::Rejected(err) => {
578 assert_eq!(err.reason.kind(), InvalidationKind::NonceReplayed);
579 if let InvalidationReason::NonceReplayed {
580 observation_count, ..
581 } = &err.reason
582 {
583 assert!(*observation_count >= 1);
584 } else {
585 panic!("unexpected variant");
586 }
587 }
588 ValidationOutcome::Ok { .. } => panic!("expected replay reject"),
589 }
590 }
591
592 #[test]
593 fn rejects_nonce_mismatch() {
594 // A multi-use token that *was* issued with nonce A but
595 // is being submitted with nonce B. The validator
596 // records nonces per-vendor, so nonce B is fresh
597 // (first observation) — but the contract's `nonce`
598 // field says A. We do not currently reject a fresh
599 // nonce against an older contract's nonce (the runner
600 // is responsible for surfacing that the contract itself
601 // has changed). However, we *do* reject a missing nonce
602 // when the policy requires one.
603 let v = validator();
604 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
605 let mut c = contract(
606 VendorId::Cloudflare,
607 ChallengeClass::Interstitial,
608 Duration::from_mins(30),
609 "nonce-A",
610 true,
611 None,
612 0,
613 );
614 c.nonce = String::new();
615 let outcome = v.validate(&c, None, 60);
616 match outcome {
617 ValidationOutcome::Rejected(err) => {
618 assert_eq!(err.reason.kind(), InvalidationKind::NonceMismatch);
619 assert_eq!(err.reason.vendor_family(), VendorId::Cloudflare);
620 assert_eq!(err.reason.challenge_class(), ChallengeClass::Interstitial);
621 }
622 ValidationOutcome::Ok { .. } => panic!("expected missing-nonce reject"),
623 }
624 }
625
626 #[test]
627 fn rejects_session_binding_miss_when_required() {
628 // Akamai requires session binding.
629 let v = validator();
630 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
631 let c = contract(
632 VendorId::Akamai,
633 ChallengeClass::ProofOfWork,
634 Duration::from_mins(15),
635 "akamai-n",
636 true,
637 Some("session-A"),
638 0,
639 );
640 let miss = v.validate(&c, Some("session-B"), 60);
641 assert!(matches!(
642 miss,
643 ValidationOutcome::Rejected(ref err) if err.reason.kind() == InvalidationKind::SessionBindingMiss
644 ));
645 let hit = v.validate(&c, Some("session-A"), 60);
646 // Wait, session-A nonce is already consumed by the
647 // first attempt? No — the first attempt was rejected
648 // (session binding miss), so the nonce was NOT
649 // recorded. The second attempt with matching session
650 // should accept.
651 assert!(matches!(hit, ValidationOutcome::Ok { .. }));
652 }
653
654 #[test]
655 fn rejects_not_applicable_combination() {
656 let v = validator();
657 // Cloudflare does not issue ProofOfWork tokens.
658 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
659 let c = contract(
660 VendorId::Cloudflare,
661 ChallengeClass::ProofOfWork,
662 Duration::from_mins(30),
663 "cf-pow",
664 true,
665 None,
666 0,
667 );
668 let outcome = v.validate(&c, None, 60);
669 match outcome {
670 ValidationOutcome::Rejected(err) => {
671 assert_eq!(err.reason.kind(), InvalidationKind::NotApplicable);
672 }
673 ValidationOutcome::Ok { .. } => panic!("expected not-applicable reject"),
674 }
675 }
676
677 #[test]
678 fn vendor_policy_lookup_returns_tier2_defaults() {
679 let v = validator();
680 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
681 let c = contract(
682 VendorId::DataDome,
683 ChallengeClass::Captcha,
684 Duration::from_mins(10),
685 "dd-1",
686 true,
687 None,
688 0,
689 );
690 let policy = v.policy().policy(c.vendor_family);
691 assert_eq!(policy.default_ttl(), Duration::from_mins(10));
692 assert!(policy.single_use());
693 assert!(policy.require_session_binding());
694 assert!(policy.require_nonce());
695 }
696
697 #[test]
698 fn ttl_is_clamped_to_vendor_max() {
699 let v = validator();
700 // Cloudflare's max ttl is 45 minutes. Submit a contract
701 // claiming a 60-minute TTL.
702 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
703 let c = contract(
704 VendorId::Cloudflare,
705 ChallengeClass::Interstitial,
706 Duration::from_hours(1),
707 "cf-long",
708 true,
709 None,
710 0,
711 );
712 let outcome = v.validate(&c, None, 40 * 60);
713 match outcome {
714 ValidationOutcome::Ok { effective_ttl, .. } => {
715 assert!(approx_eq(effective_ttl, Duration::from_mins(45)));
716 }
717 ValidationOutcome::Rejected(err) => panic!("unexpected reject: {err:?}"),
718 }
719 }
720
721 #[test]
722 fn multi_use_token_does_not_replay_reject() {
723 let v = validator();
724 // FingerprintCom default policy: single_use = false.
725 // Re-submitting the same nonce must not trip replay.
726 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
727 let c = contract(
728 VendorId::FingerprintCom,
729 ChallengeClass::None,
730 Duration::from_hours(1),
731 "fp-n",
732 false,
733 None,
734 0,
735 );
736 let first = v.validate(&c, None, 60);
737 assert!(matches!(first, ValidationOutcome::Ok { .. }));
738 let second = v.validate(&c, None, 60);
739 match second {
740 ValidationOutcome::Ok { consumed, .. } => assert!(!consumed),
741 ValidationOutcome::Rejected(err) => panic!("unexpected reject: {err:?}"),
742 }
743 }
744
745 #[test]
746 fn validation_outcome_helpers() {
747 let ok = ValidationOutcome::Ok {
748 // codeql[rust/hard-coded-cryptographic-value] false-positive: deterministic test label
749 contract: contract(
750 VendorId::Unknown,
751 ChallengeClass::None,
752 Duration::from_mins(1),
753 "x",
754 false,
755 None,
756 0,
757 ),
758 consumed: false,
759 effective_ttl: Duration::from_mins(1),
760 };
761 assert!(ok.is_ok());
762 assert!(!ok.is_rejected());
763 assert!(ok.error().is_none());
764
765 let rejected = ValidationOutcome::Rejected(TokenLifecycleError::new(
766 InvalidationReason::ContractMissing {
767 vendor: VendorId::Unknown,
768 challenge_class: ChallengeClass::Unknown,
769 },
770 "no contract",
771 ));
772 assert!(!rejected.is_ok());
773 assert!(rejected.is_rejected());
774 assert_eq!(
775 rejected.invalidation_kind(),
776 Some(InvalidationKind::ContractMissing)
777 );
778 }
779}