Skip to main content

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}