Skip to main content

stygian_browser/freshness/
mod.rs

1//! Fingerprint freshness contracts for browser identity reuse.
2//!
3//! Browser identity artifacts — fingerprints, sticky sessions, and
4//! challenge contexts — must not be reused past a safe age, across
5//! incompatible targets, or when their underlying signature has rotated.
6//! This module provides a deterministic freshness decision function that
7//! callers can plug into the [`acquisition`][crate::acquisition] runner
8//! and the stealth v3 identity paths to reject stale or mismatched
9//! artifacts before they are reused.
10//!
11//! ## Feature flag
12//!
13//! This module is **default-on** and is always compiled as part of the
14//! `stygian-browser` crate. The [`AcquisitionRunner`][crate::acquisition::AcquisitionRunner]
15//! and stealth v3 paths consult the freshness check on every reuse so
16//! integration tests gated on those features exercise it.
17//!
18//! ## Domain-aware TTL defaults
19//!
20//! [`FreshnessPolicy::for_domain`] resolves a max-age using four
21//! [`DomainClass`]es that callers can tune via the
22//! `domain_class_overrides` map:
23//!
24//! - [`DomainClass::Sensitive`] (default `120 s`) — auth, payment, or
25//!   challenge-issuing endpoints.
26//! - [`DomainClass::Authenticated`] (default `600 s`) — logged-in
27//!   user surfaces.
28//! - [`DomainClass::Hostile`] (default `300 s`) — known anti-bot
29//!   targets.
30//! - [`DomainClass::Default`] (default `1800 s`) — generic targets.
31//!
32//! ## Telemetry fields
33//!
34//! Every non-[`FreshnessDecision::Valid`] decision carries an
35//! [`InvalidationReason`] whose fields explain *why* the artifact was
36//! rejected (observed vs. contract domain, observed vs. contract
37//! signature, captured vs. observed timestamp, elapsed vs. max-age).
38//! The runner emits these fields via `tracing::warn!` and the
39//! [`FreshnessReport`] attached to the acquisition result.
40//!
41//! # Example
42//!
43//! ```
44//! use stygian_browser::freshness::{
45//!     DomainClass, FreshnessCheckInput, FreshnessContract, FreshnessPolicy,
46//!     FreshnessPolicyKind, check,
47//! };
48//! use std::time::Duration;
49//!
50//! let policy = FreshnessPolicy::default();
51//! let contract = FreshnessContract::with_signature(
52//!     "example.com",
53//!     "sha256:abc123",
54//!     1_700_000_000_000,
55//!     Duration::from_millis(policy.max_age_ms_for(DomainClass::Default)),
56//!     FreshnessPolicyKind::Standard,
57//! )
58//! .expect("valid contract");
59//! let decision = check(
60//!     &contract,
61//!     &FreshnessCheckInput::new("example.com", Some("sha256:abc123"), 1_700_000_060_000),
62//! );
63//! assert!(decision.is_valid());
64//! ```
65
66use std::collections::HashMap;
67use std::fmt;
68use std::time::{Duration, SystemTime, UNIX_EPOCH};
69
70use serde::{Deserialize, Serialize};
71use thiserror::Error;
72
73// ─── Error type ───────────────────────────────────────────────────────────────
74
75/// Errors produced by freshness contract construction.
76#[derive(Debug, Error)]
77pub enum FreshnessError {
78    /// Contract could not be serialised or deserialised.
79    #[error("failed to (de)serialise freshness contract: {0}")]
80    Serialization(String),
81    /// Contract carried an invalid field (empty domain, zero max-age, etc.).
82    #[error("invalid freshness contract: {0}")]
83    InvalidContract(String),
84}
85
86impl From<serde_json::Error> for FreshnessError {
87    fn from(err: serde_json::Error) -> Self {
88        Self::Serialization(err.to_string())
89    }
90}
91
92// ─── Policy ───────────────────────────────────────────────────────────────────
93
94/// Coarse policy band for a freshness contract.
95///
96/// Higher bands require shorter maximum ages and stricter signature
97/// matching, lowering the chance of reusing an identity that anti-bot
98/// vendors may have already catalogued.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum FreshnessPolicyKind {
102    /// Shortest TTLs, signatures must match.
103    Strict,
104    /// Default TTLs, signatures preferred but optional.
105    Standard,
106    /// Longer TTLs, best-effort validation.
107    Permissive,
108}
109
110/// Domain classification that controls default max-age selection.
111///
112/// The [`FreshnessPolicy`] resolves one of these classes per host via
113/// [`FreshnessPolicy::for_domain`].
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum DomainClass {
117    /// Generic target — longer TTL is safe.
118    Default,
119    /// Hostile anti-bot target — short TTL to limit exposure.
120    Hostile,
121    /// Authenticated surface — moderate TTL because the user is logged in.
122    Authenticated,
123    /// Sensitive target (auth issuer, payment, challenge endpoint) —
124    /// shortest TTL.
125    Sensitive,
126}
127
128impl DomainClass {
129    /// String label used in telemetry output.
130    #[must_use]
131    pub const fn label(self) -> &'static str {
132        match self {
133            Self::Default => "default",
134            Self::Hostile => "hostile",
135            Self::Authenticated => "authenticated",
136            Self::Sensitive => "sensitive",
137        }
138    }
139}
140
141/// Configurable TTL and signature policy for freshness contracts.
142///
143/// The default TTLs are tuned for typical scraping workflows; callers
144/// can override them per-policy via [`FreshnessPolicy::with_overrides`]
145/// or per-domain via [`FreshnessPolicy::with_domain_override`].
146///
147/// # Example
148///
149/// ```
150/// use stygian_browser::freshness::{DomainClass, FreshnessPolicy};
151/// use std::time::Duration;
152///
153/// let mut policy = FreshnessPolicy::default();
154/// policy = policy.with_domain_override("accounts.example.com", Some(DomainClass::Sensitive));
155/// assert_eq!(policy.class_for("accounts.example.com"), DomainClass::Sensitive);
156/// assert_eq!(
157///     policy.max_age_for("accounts.example.com"),
158///     Duration::from_millis(policy.max_age_ms_for(DomainClass::Sensitive))
159/// );
160/// ```
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct FreshnessPolicy {
163    /// Policy band for short-circuit classification (logging, telemetry).
164    pub kind: FreshnessPolicyKind,
165    /// Per-domain class overrides keyed by lowercased host.
166    pub domain_class_overrides: HashMap<String, DomainClass>,
167    /// Default max-age in milliseconds.
168    pub default_max_age_ms: u64,
169    /// Max-age for [`DomainClass::Hostile`] in milliseconds.
170    pub hostile_max_age_ms: u64,
171    /// Max-age for [`DomainClass::Authenticated`] in milliseconds.
172    pub authenticated_max_age_ms: u64,
173    /// Max-age for [`DomainClass::Sensitive`] in milliseconds.
174    pub sensitive_max_age_ms: u64,
175    /// When `true`, contracts without a signature are rejected
176    /// regardless of TTL.
177    pub signature_required: bool,
178}
179
180impl Default for FreshnessPolicy {
181    fn default() -> Self {
182        Self {
183            kind: FreshnessPolicyKind::Standard,
184            domain_class_overrides: HashMap::new(),
185            default_max_age_ms: 1_800_000,
186            hostile_max_age_ms: 300_000,
187            authenticated_max_age_ms: 600_000,
188            sensitive_max_age_ms: 120_000,
189            signature_required: false,
190        }
191    }
192}
193
194impl FreshnessPolicy {
195    /// Build a policy with explicit `kind` and default TTLs.
196    #[must_use]
197    pub fn with_kind(kind: FreshnessPolicyKind) -> Self {
198        Self {
199            kind,
200            domain_class_overrides: HashMap::new(),
201            default_max_age_ms: 1_800_000,
202            hostile_max_age_ms: 300_000,
203            authenticated_max_age_ms: 600_000,
204            sensitive_max_age_ms: 120_000,
205            signature_required: false,
206        }
207    }
208
209    /// Tighten all TTLs by `factor` (e.g. `0.5` for half-life).
210    #[must_use]
211    pub const fn tightened(mut self, factor: f64) -> Self {
212        let factor = factor.clamp(0.01, 1.0);
213        self.default_max_age_ms = scale_ms(self.default_max_age_ms, factor);
214        self.hostile_max_age_ms = scale_ms(self.hostile_max_age_ms, factor);
215        self.authenticated_max_age_ms = scale_ms(self.authenticated_max_age_ms, factor);
216        self.sensitive_max_age_ms = scale_ms(self.sensitive_max_age_ms, factor);
217        self
218    }
219
220    /// Override the [`DomainClass`] for `host`.
221    ///
222    /// `host` is normalised to lower-case ASCII before being inserted.
223    /// Pass `None` to clear an existing override.
224    #[must_use]
225    pub fn with_domain_override(mut self, host: &str, class: Option<DomainClass>) -> Self {
226        let key = host.trim().to_ascii_lowercase();
227        match class {
228            Some(c) => {
229                self.domain_class_overrides.insert(key, c);
230            }
231            None => {
232                self.domain_class_overrides.remove(&key);
233            }
234        }
235        self
236    }
237
238    /// Replace the full override map at once.
239    #[must_use]
240    pub fn with_overrides(mut self, overrides: HashMap<String, DomainClass>) -> Self {
241        self.domain_class_overrides = overrides;
242        self
243    }
244
245    /// Set whether contracts without a signature are rejected.
246    #[must_use]
247    pub const fn with_signature_required(mut self, required: bool) -> Self {
248        self.signature_required = required;
249        self
250    }
251
252    /// Resolve the [`DomainClass`] for a host.
253    ///
254    /// Lookup walks the override map first, then falls back to
255    /// heuristics that recognise well-known challenge issuers
256    /// (`captcha`, `challenge`, `auth`, `login`, `accounts`,
257    /// `payment`, `checkout`) as [`DomainClass::Sensitive`].
258    #[must_use]
259    pub fn class_for(&self, host: &str) -> DomainClass {
260        let key = host.trim().to_ascii_lowercase();
261        if let Some(class) = self.domain_class_overrides.get(&key).copied() {
262            return class;
263        }
264        heuristic_class(&key)
265    }
266
267    /// Convenience wrapper that returns the [`DomainClass`] for `host`
268    /// via [`Self::class_for`].
269    #[must_use]
270    pub fn for_domain(&self, host: &str) -> DomainClass {
271        self.class_for(host)
272    }
273
274    /// Max-age in milliseconds for a given class.
275    #[must_use]
276    pub const fn max_age_ms_for(&self, class: DomainClass) -> u64 {
277        match class {
278            DomainClass::Default => self.default_max_age_ms,
279            DomainClass::Hostile => self.hostile_max_age_ms,
280            DomainClass::Authenticated => self.authenticated_max_age_ms,
281            DomainClass::Sensitive => self.sensitive_max_age_ms,
282        }
283    }
284
285    /// Max-age as a [`Duration`] for `host`.
286    #[must_use]
287    pub fn max_age_for(&self, host: &str) -> Duration {
288        Duration::from_millis(self.max_age_ms_for(self.class_for(host)))
289    }
290
291    /// Build a contract for `host` capturing the current wall-clock
292    /// and `signature` (when known). The max-age is resolved via
293    /// [`Self::max_age_for`].
294    ///
295    /// # Errors
296    ///
297    /// Returns [`FreshnessError::InvalidContract`] when `host` is empty
298    /// or `signature` is empty.
299    pub fn build_contract(
300        &self,
301        host: &str,
302        signature: Option<&str>,
303    ) -> Result<FreshnessContract, FreshnessError> {
304        let class = self.class_for(host);
305        let max_age_ms = self.max_age_ms_for(class);
306        FreshnessContract::with_signature(
307            host,
308            signature.unwrap_or(""),
309            unix_epoch_ms(),
310            Duration::from_millis(max_age_ms),
311            self.kind,
312        )
313        .map(|mut c| {
314            c.domain_class = class;
315            c
316        })
317    }
318}
319
320fn heuristic_class(host: &str) -> DomainClass {
321    const SENSITIVE_TOKENS: &[&str] = &[
322        "captcha",
323        "challenge",
324        "auth",
325        "login",
326        "signin",
327        "accounts",
328        "payment",
329        "checkout",
330        "verify",
331    ];
332    const HOSTILE_TOKENS: &[&str] = &["cloudflare", "datadome", "perimeter", "akamai", "kasada"];
333
334    for token in SENSITIVE_TOKENS {
335        if host.contains(token) {
336            return DomainClass::Sensitive;
337        }
338    }
339    for token in HOSTILE_TOKENS {
340        if host.contains(token) {
341            return DomainClass::Hostile;
342        }
343    }
344    DomainClass::Default
345}
346
347#[allow(
348    clippy::cast_precision_loss,
349    clippy::cast_sign_loss,
350    clippy::cast_possible_truncation
351)]
352const fn scale_ms(value: u64, factor: f64) -> u64 {
353    let scaled = (value as f64) * factor;
354    if !scaled.is_finite() || scaled <= 0.0 {
355        1
356    } else if scaled > u64::MAX as f64 {
357        u64::MAX
358    } else {
359        scaled as u64
360    }
361}
362
363// ─── Contract ─────────────────────────────────────────────────────────────────
364
365/// A freshness contract describing the origin and constraints of an
366/// identity artifact.
367///
368/// Capture time, target domain, optional signature hash, and the
369/// resolved max-age are all bound at the point of capture so a later
370/// [`check`] can detect any of:
371///
372/// - TTL expiration (`now - captured_at > max_age`)
373/// - Signature rotation (`signature` field mismatch)
374/// - Domain rebinding (`domain` field mismatch)
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct FreshnessContract {
377    /// Lower-cased host the contract was bound to (e.g. `"example.com"`).
378    pub domain: String,
379    /// Optional opaque signature hash (e.g. `"sha256:abc…"`) the
380    /// contract was bound to. `None` when signatures are not used.
381    pub signature_hash: Option<String>,
382    /// Unix epoch milliseconds when the contract was captured.
383    pub captured_at_epoch_ms: u64,
384    /// Resolved max-age for this contract.
385    #[serde(with = "duration_ms")]
386    pub max_age: Duration,
387    /// Policy band used to resolve `max_age`.
388    pub policy_kind: FreshnessPolicyKind,
389    /// Domain class used to resolve `max_age`.
390    pub domain_class: DomainClass,
391}
392
393impl FreshnessContract {
394    /// Build a contract with explicit fields.
395    ///
396    /// # Errors
397    ///
398    /// Returns [`FreshnessError::InvalidContract`] when `domain` is
399    /// empty after trimming, when `max_age` is zero, or when
400    /// `signature` is provided but empty.
401    pub fn with_signature(
402        domain: &str,
403        signature: &str,
404        captured_at_epoch_ms: u64,
405        max_age: Duration,
406        policy_kind: FreshnessPolicyKind,
407    ) -> Result<Self, FreshnessError> {
408        let domain = domain.trim().to_ascii_lowercase();
409        if domain.is_empty() {
410            return Err(FreshnessError::InvalidContract(
411                "domain must not be empty".to_string(),
412            ));
413        }
414        if max_age.is_zero() {
415            return Err(FreshnessError::InvalidContract(
416                "max_age must be > 0".to_string(),
417            ));
418        }
419        let signature_hash = if signature.is_empty() {
420            None
421        } else {
422            Some(signature.to_string())
423        };
424        Ok(Self {
425            domain,
426            signature_hash,
427            captured_at_epoch_ms,
428            max_age,
429            policy_kind,
430            domain_class: DomainClass::Default,
431        })
432    }
433
434    /// Build a contract without a signature.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`FreshnessError::InvalidContract`] when `domain` is
439    /// empty after trimming or when `max_age` is zero.
440    pub fn without_signature(
441        domain: &str,
442        captured_at_epoch_ms: u64,
443        max_age: Duration,
444        policy_kind: FreshnessPolicyKind,
445    ) -> Result<Self, FreshnessError> {
446        Self::with_signature(domain, "", captured_at_epoch_ms, max_age, policy_kind)
447    }
448
449    /// Convenience constructor that captures the current wall-clock.
450    ///
451    /// # Errors
452    ///
453    /// See [`Self::with_signature`].
454    pub fn capture_now(
455        domain: &str,
456        signature: Option<&str>,
457        max_age: Duration,
458        policy_kind: FreshnessPolicyKind,
459    ) -> Result<Self, FreshnessError> {
460        Self::with_signature(
461            domain,
462            signature.unwrap_or(""),
463            unix_epoch_ms(),
464            max_age,
465            policy_kind,
466        )
467    }
468
469    /// Resolved max-age in milliseconds.
470    #[must_use]
471    #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
472    pub const fn max_age_ms(&self) -> u64 {
473        // Duration::as_millis returns u128; clamp to u64 for telemetry keys.
474        let v = self.max_age.as_millis();
475        if v > u64::MAX as u128 {
476            u64::MAX
477        } else {
478            v as u64
479        }
480    }
481}
482
483// serde helper: serialise Duration as integer milliseconds
484mod duration_ms {
485    use serde::{Deserialize, Deserializer, Serializer};
486    use std::time::Duration;
487
488    #[allow(clippy::cast_possible_truncation)]
489    pub fn serialize<S: Serializer>(value: &Duration, ser: S) -> Result<S::Ok, S::Error> {
490        let ms = value.as_millis();
491        let n = if ms > u128::from(u64::MAX) {
492            u64::MAX
493        } else {
494            ms as u64
495        };
496        ser.serialize_u64(n)
497    }
498
499    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
500        let ms = u64::deserialize(de)?;
501        Ok(Duration::from_millis(ms))
502    }
503}
504
505// ─── Decision ─────────────────────────────────────────────────────────────────
506
507/// Structured reason a freshness contract was invalidated.
508///
509/// All fields are populated regardless of which rule fired so
510/// telemetry always carries the full context.
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512pub struct InvalidationReason {
513    /// Contract's bound domain (lower-case).
514    pub contract_domain: String,
515    /// Observed domain passed to [`check`].
516    pub observed_domain: String,
517    /// Contract's bound signature (when set).
518    pub contract_signature: Option<String>,
519    /// Observed signature passed to [`check`] (when set).
520    pub observed_signature: Option<String>,
521    /// Contract's captured-at timestamp.
522    pub captured_at_epoch_ms: u64,
523    /// Observed timestamp passed to [`check`].
524    pub observed_at_epoch_ms: u64,
525    /// Elapsed milliseconds between capture and observation.
526    pub elapsed_ms: u64,
527    /// Contract's max-age in milliseconds.
528    pub max_age_ms: u64,
529    /// Policy band used.
530    pub policy_kind: FreshnessPolicyKind,
531    /// Domain class used.
532    pub domain_class: DomainClass,
533    /// Stable machine-readable reason tag.
534    pub kind: InvalidationKind,
535}
536
537/// Machine-readable reason tag attached to [`InvalidationReason`].
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
539#[serde(rename_all = "snake_case")]
540pub enum InvalidationKind {
541    /// Elapsed since capture exceeded max-age.
542    StaleTtl,
543    /// Signature hash did not match.
544    SignatureMismatch,
545    /// Target domain did not match the contract's domain.
546    DomainMismatch,
547    /// Contract had no signature but policy requires one.
548    SignatureMissing,
549}
550
551impl InvalidationKind {
552    /// Stable string label.
553    #[must_use]
554    pub const fn as_str(self) -> &'static str {
555        match self {
556            Self::StaleTtl => "stale_ttl",
557            Self::SignatureMismatch => "signature_mismatch",
558            Self::DomainMismatch => "domain_mismatch",
559            Self::SignatureMissing => "signature_missing",
560        }
561    }
562}
563
564impl fmt::Display for InvalidationKind {
565    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
566        f.write_str(self.as_str())
567    }
568}
569
570/// Decision produced by [`check`].
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(tag = "outcome", rename_all = "snake_case")]
573pub enum FreshnessDecision {
574    /// Contract is still valid for the observed context.
575    Valid,
576    /// Contract expired (`elapsed > max_age`).
577    StaleTtl {
578        /// Structured invalidation reason.
579        reason: Box<InvalidationReason>,
580    },
581    /// Signature hash did not match the observed value.
582    SignatureMismatch {
583        /// Structured invalidation reason.
584        reason: Box<InvalidationReason>,
585    },
586    /// Target domain did not match the contract's domain.
587    DomainMismatch {
588        /// Structured invalidation reason.
589        reason: Box<InvalidationReason>,
590    },
591}
592
593impl FreshnessDecision {
594    /// `true` when the contract is valid.
595    #[must_use]
596    pub const fn is_valid(&self) -> bool {
597        matches!(self, Self::Valid)
598    }
599
600    /// `true` when the contract is invalid (any non-Valid variant).
601    #[must_use]
602    pub const fn is_invalid(&self) -> bool {
603        !self.is_valid()
604    }
605
606    /// Invalid [`InvalidationReason`] when the decision is non-Valid.
607    #[must_use]
608    pub fn reason(&self) -> Option<&InvalidationReason> {
609        match self {
610            Self::Valid => None,
611            Self::StaleTtl { reason }
612            | Self::SignatureMismatch { reason }
613            | Self::DomainMismatch { reason } => Some(reason),
614        }
615    }
616
617    /// Stable machine-readable label for the decision.
618    #[must_use]
619    #[allow(clippy::missing_const_for_fn)]
620    pub fn label(&self) -> &'static str {
621        match self {
622            Self::Valid => "valid",
623            Self::StaleTtl { .. } => "stale_ttl",
624            Self::SignatureMismatch { .. } => "signature_mismatch",
625            Self::DomainMismatch { .. } => "domain_mismatch",
626        }
627    }
628}
629
630impl fmt::Display for FreshnessDecision {
631    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632        match self {
633            Self::Valid => f.write_str("valid"),
634            Self::StaleTtl { reason }
635            | Self::SignatureMismatch { reason }
636            | Self::DomainMismatch { reason } => {
637                write!(f, "{} ({})", self.label(), reason.kind)
638            }
639        }
640    }
641}
642
643// ─── Input ────────────────────────────────────────────────────────────────────
644
645/// Observed context passed to [`check`].
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct FreshnessCheckInput {
648    /// Lower-cased target host observed at reuse time.
649    pub observed_domain: String,
650    /// Lower-cased observed signature hash, when available.
651    pub observed_signature: Option<String>,
652    /// Observation timestamp (Unix epoch ms).
653    pub observed_at_epoch_ms: u64,
654}
655
656impl FreshnessCheckInput {
657    /// Build a check input.
658    #[must_use]
659    pub fn new(
660        observed_domain: &str,
661        observed_signature: Option<&str>,
662        observed_at_epoch_ms: u64,
663    ) -> Self {
664        let observed_domain = observed_domain.trim().to_ascii_lowercase();
665        let observed_signature = observed_signature
666            .filter(|s| !s.is_empty())
667            .map(str::to_string);
668        Self {
669            observed_domain,
670            observed_signature,
671            observed_at_epoch_ms,
672        }
673    }
674
675    /// Build an input capturing the current wall-clock for `host`.
676    #[must_use]
677    pub fn capture_now(observed_domain: &str, observed_signature: Option<&str>) -> Self {
678        Self::new(observed_domain, observed_signature, unix_epoch_ms())
679    }
680}
681
682// ─── Freshness check ──────────────────────────────────────────────────────────
683
684/// Evaluate `contract` against `input`, returning a deterministic
685/// [`FreshnessDecision`].
686///
687/// Precedence:
688///
689/// 1. Domain mismatch is checked first (cheap, structural).
690/// 2. Signature missing-when-required is checked next.
691/// 3. Signature mismatch is checked before TTL so a rotated signature
692///    never silently slips through on an unexpired contract.
693/// 4. TTL (elapsed > max-age) is the last gate.
694///
695/// The decision is fully determined by `(contract, input)` — no I/O,
696/// no clock reads.
697#[must_use]
698pub fn check(contract: &FreshnessContract, input: &FreshnessCheckInput) -> FreshnessDecision {
699    let elapsed_ms = input
700        .observed_at_epoch_ms
701        .saturating_sub(contract.captured_at_epoch_ms);
702
703    // 1. Domain mismatch
704    if contract.domain != input.observed_domain {
705        return FreshnessDecision::DomainMismatch {
706            reason: Box::new(InvalidationReason {
707                contract_domain: contract.domain.clone(),
708                observed_domain: input.observed_domain.clone(),
709                contract_signature: contract.signature_hash.clone(),
710                observed_signature: input.observed_signature.clone(),
711                captured_at_epoch_ms: contract.captured_at_epoch_ms,
712                observed_at_epoch_ms: input.observed_at_epoch_ms,
713                elapsed_ms,
714                max_age_ms: contract.max_age_ms(),
715                policy_kind: contract.policy_kind,
716                domain_class: contract.domain_class,
717                kind: InvalidationKind::DomainMismatch,
718            }),
719        };
720    }
721
722    // 2. Signature required but missing
723    if contract.signature_hash.is_none() && input.observed_signature.is_some() {
724        return FreshnessDecision::SignatureMismatch {
725            reason: Box::new(InvalidationReason {
726                contract_domain: contract.domain.clone(),
727                observed_domain: input.observed_domain.clone(),
728                contract_signature: contract.signature_hash.clone(),
729                observed_signature: input.observed_signature.clone(),
730                captured_at_epoch_ms: contract.captured_at_epoch_ms,
731                observed_at_epoch_ms: input.observed_at_epoch_ms,
732                elapsed_ms,
733                max_age_ms: contract.max_age_ms(),
734                policy_kind: contract.policy_kind,
735                domain_class: contract.domain_class,
736                kind: InvalidationKind::SignatureMissing,
737            }),
738        };
739    }
740
741    // 3. Signature mismatch
742    if let (Some(expected), Some(observed)) = (&contract.signature_hash, &input.observed_signature)
743        && expected != observed
744    {
745        return FreshnessDecision::SignatureMismatch {
746            reason: Box::new(InvalidationReason {
747                contract_domain: contract.domain.clone(),
748                observed_domain: input.observed_domain.clone(),
749                contract_signature: Some(expected.clone()),
750                observed_signature: Some(observed.clone()),
751                captured_at_epoch_ms: contract.captured_at_epoch_ms,
752                observed_at_epoch_ms: input.observed_at_epoch_ms,
753                elapsed_ms,
754                max_age_ms: contract.max_age_ms(),
755                policy_kind: contract.policy_kind,
756                domain_class: contract.domain_class,
757                kind: InvalidationKind::SignatureMismatch,
758            }),
759        };
760    }
761
762    // 4. TTL
763    if elapsed_ms > contract.max_age_ms() {
764        return FreshnessDecision::StaleTtl {
765            reason: Box::new(InvalidationReason {
766                contract_domain: contract.domain.clone(),
767                observed_domain: input.observed_domain.clone(),
768                contract_signature: contract.signature_hash.clone(),
769                observed_signature: input.observed_signature.clone(),
770                captured_at_epoch_ms: contract.captured_at_epoch_ms,
771                observed_at_epoch_ms: input.observed_at_epoch_ms,
772                elapsed_ms,
773                max_age_ms: contract.max_age_ms(),
774                policy_kind: contract.policy_kind,
775                domain_class: contract.domain_class,
776                kind: InvalidationKind::StaleTtl,
777            }),
778        };
779    }
780
781    FreshnessDecision::Valid
782}
783
784// ─── Telemetry helper ─────────────────────────────────────────────────────────
785
786/// Compact freshness report attached to acquisition results and
787/// emitted via `tracing`.
788///
789/// Includes both the decision and (when invalidated) the structured
790/// reason fields, so downstream automation can attribute rejections
791/// without re-parsing log strings.
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793pub struct FreshnessReport {
794    /// Resolved decision for this run.
795    pub decision: FreshnessDecision,
796    /// Resolved [`DomainClass`] for the target host.
797    pub domain_class: DomainClass,
798    /// Policy band used.
799    pub policy_kind: FreshnessPolicyKind,
800    /// Whether the contract was considered (vs. no contract supplied).
801    pub contract_evaluated: bool,
802}
803
804impl FreshnessReport {
805    /// A no-contract report (`Valid`, no evaluation performed).
806    #[must_use]
807    #[allow(clippy::missing_const_for_fn)]
808    pub fn skipped(policy_kind: FreshnessPolicyKind, domain_class: DomainClass) -> Self {
809        Self {
810            decision: FreshnessDecision::Valid,
811            domain_class,
812            policy_kind,
813            contract_evaluated: false,
814        }
815    }
816
817    /// Build a report from a contract + input pair.
818    #[must_use]
819    pub fn evaluate(contract: &FreshnessContract, input: &FreshnessCheckInput) -> Self {
820        Self {
821            decision: check(contract, input),
822            domain_class: contract.domain_class,
823            policy_kind: contract.policy_kind,
824            contract_evaluated: true,
825        }
826    }
827
828    /// Emit a structured `tracing` event for this report.
829    pub fn log(&self) {
830        match &self.decision {
831            FreshnessDecision::Valid => {
832                if self.contract_evaluated {
833                    tracing::debug!(
834                        target: "stygian::freshness",
835                        decision = self.decision.label(),
836                        domain_class = self.domain_class.label(),
837                        policy = policy_label(self.policy_kind),
838                        "freshness contract is valid",
839                    );
840                }
841            }
842            FreshnessDecision::StaleTtl { reason }
843            | FreshnessDecision::SignatureMismatch { reason }
844            | FreshnessDecision::DomainMismatch { reason } => {
845                tracing::warn!(
846                    target: "stygian::freshness",
847                    decision = self.decision.label(),
848                    invalidation_reason = reason.kind.as_str(),
849                    contract_domain = %reason.contract_domain,
850                    observed_domain = %reason.observed_domain,
851                    contract_signature = reason.contract_signature.as_deref().unwrap_or(""),
852                    observed_signature = reason.observed_signature.as_deref().unwrap_or(""),
853                    captured_at_epoch_ms = reason.captured_at_epoch_ms,
854                    observed_at_epoch_ms = reason.observed_at_epoch_ms,
855                    elapsed_ms = reason.elapsed_ms,
856                    max_age_ms = reason.max_age_ms,
857                    domain_class = self.domain_class.label(),
858                    policy = policy_label(self.policy_kind),
859                    "freshness contract invalidated",
860                );
861            }
862        }
863    }
864}
865
866const fn policy_label(kind: FreshnessPolicyKind) -> &'static str {
867    match kind {
868        FreshnessPolicyKind::Strict => "strict",
869        FreshnessPolicyKind::Standard => "standard",
870        FreshnessPolicyKind::Permissive => "permissive",
871    }
872}
873
874// ─── Helpers ──────────────────────────────────────────────────────────────────
875
876/// Current Unix epoch in milliseconds, clamped to `u64`.
877///
878/// Saturates to `0` if the clock is before the epoch (theoretical).
879#[must_use]
880pub fn unix_epoch_ms() -> u64 {
881    SystemTime::now()
882        .duration_since(UNIX_EPOCH)
883        .map_or(Duration::ZERO, |d| d)
884        .as_millis()
885        .try_into()
886        .unwrap_or(u64::MAX)
887}
888
889/// Produce a stable, low-cost signature hash for an arbitrary list of
890/// string fields.
891///
892/// Returns a `"fnv64:<hex>"` string suitable for use as a
893/// [`FreshnessContract::signature_hash`]. The function is pure and
894/// deterministic — equal inputs always produce the same output.
895///
896/// # Example
897///
898/// ```
899/// use stygian_browser::freshness::signature_hash;
900///
901/// let h = signature_hash(&["example.com", "MacIntel", "1920x1080"]);
902/// assert!(h.starts_with("fnv64:"));
903/// assert_eq!(h, signature_hash(&["example.com", "MacIntel", "1920x1080"]));
904/// ```
905#[must_use]
906pub fn signature_hash(parts: &[&str]) -> String {
907    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
908    const PRIME: u64 = 0x0000_0100_0000_01b3;
909
910    let mut hash = OFFSET;
911    for part in parts {
912        for byte in part.as_bytes() {
913            hash ^= u64::from(*byte);
914            hash = hash.wrapping_mul(PRIME);
915        }
916        // separator: 0x1f (unit separator)
917        hash ^= 0x1f;
918        hash = hash.wrapping_mul(PRIME);
919    }
920    format!("fnv64:{hash:016x}")
921}
922
923// ─── Tests ────────────────────────────────────────────────────────────────────
924
925#[cfg(test)]
926#[allow(
927    clippy::unwrap_used,
928    clippy::expect_used,
929    clippy::panic,
930    clippy::indexing_slicing
931)]
932mod tests {
933    use super::*;
934
935    const CAPTURED_AT: u64 = 1_700_000_000_000;
936
937    fn contract(max_age_ms: u64, sig: Option<&str>) -> FreshnessContract {
938        FreshnessContract::with_signature(
939            "example.com",
940            sig.unwrap_or(""),
941            CAPTURED_AT,
942            Duration::from_millis(max_age_ms),
943            FreshnessPolicyKind::Standard,
944        )
945        .expect("valid contract")
946    }
947
948    fn input(observed_at_ms: u64, sig: Option<&str>) -> FreshnessCheckInput {
949        FreshnessCheckInput::new("example.com", sig, observed_at_ms)
950    }
951
952    #[test]
953    fn ttl_invalidates_past_max_age() {
954        let c = contract(1_000, Some("sha256:abc"));
955        // 2_000ms after capture -> 1_000ms past max_age -> stale
956        let decision = check(&c, &input(CAPTURED_AT + 2_000, Some("sha256:abc")));
957        assert!(matches!(
958            decision,
959            FreshnessDecision::StaleTtl { ref reason } if reason.kind == InvalidationKind::StaleTtl
960        ));
961    }
962
963    #[test]
964    fn ttl_holds_within_max_age() {
965        let c = contract(60_000, Some("sha256:abc"));
966        let decision = check(&c, &input(CAPTURED_AT + 30_000, Some("sha256:abc")));
967        assert!(decision.is_valid());
968    }
969
970    #[test]
971    fn signature_mismatch_invalidates_even_when_within_ttl() {
972        let c = contract(60_000, Some("sha256:abc"));
973        let decision = check(&c, &input(CAPTURED_AT + 1_000, Some("sha256:xyz")));
974        match decision {
975            FreshnessDecision::SignatureMismatch { reason } => {
976                assert_eq!(reason.kind, InvalidationKind::SignatureMismatch);
977                assert_eq!(reason.contract_signature.as_deref(), Some("sha256:abc"));
978                assert_eq!(reason.observed_signature.as_deref(), Some("sha256:xyz"));
979            }
980            other => panic!("expected SignatureMismatch, got {other:?}"),
981        }
982    }
983
984    #[test]
985    fn domain_mismatch_takes_precedence_over_ttl() {
986        let c = contract(60_000, Some("sha256:abc"));
987        let input = FreshnessCheckInput::new("other.example", Some("sha256:abc"), CAPTURED_AT);
988        let decision = check(&c, &input);
989        match decision {
990            FreshnessDecision::DomainMismatch { reason } => {
991                assert_eq!(reason.kind, InvalidationKind::DomainMismatch);
992                assert_eq!(reason.contract_domain, "example.com");
993                assert_eq!(reason.observed_domain, "other.example");
994            }
995            other => panic!("expected DomainMismatch, got {other:?}"),
996        }
997    }
998
999    #[test]
1000    fn missing_signature_when_required_rejects() {
1001        let policy = FreshnessPolicy {
1002            signature_required: true,
1003            ..FreshnessPolicy::default()
1004        };
1005        // Re-classify as sensitive to also test class plumbing
1006        let policy = policy.with_domain_override("example.com", Some(DomainClass::Sensitive));
1007        assert!(policy.signature_required);
1008        assert_eq!(policy.class_for("example.com"), DomainClass::Sensitive);
1009        // Build a contract without signature
1010        let c = FreshnessContract::without_signature(
1011            "example.com",
1012            CAPTURED_AT,
1013            policy.max_age_for("example.com"),
1014            policy.kind,
1015        )
1016        .expect("contract");
1017        let observed_with_sig = input(CAPTURED_AT + 1_000, Some("sha256:abc"));
1018        let decision = check(&c, &observed_with_sig);
1019        match decision {
1020            FreshnessDecision::SignatureMismatch { reason } => {
1021                assert_eq!(reason.kind, InvalidationKind::SignatureMissing);
1022            }
1023            other => panic!("expected SignatureMismatch (missing), got {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn determinism_same_inputs_same_decision() {
1029        let c = contract(60_000, Some("sha256:abc"));
1030        let i = input(CAPTURED_AT + 30_000, Some("sha256:abc"));
1031        let a = check(&c, &i);
1032        let b = check(&c, &i);
1033        assert_eq!(a, b);
1034
1035        // Deterministic for invalid cases too
1036        let c2 = contract(1_000, Some("sha256:abc"));
1037        let i2 = input(CAPTURED_AT + 5_000, Some("sha256:abc"));
1038        let a = check(&c2, &i2);
1039        let b = check(&c2, &i2);
1040        assert_eq!(a, b);
1041    }
1042
1043    #[test]
1044    fn signature_hash_is_deterministic_and_stable() {
1045        let h1 = signature_hash(&["a", "b", "c"]);
1046        let h2 = signature_hash(&["a", "b", "c"]);
1047        assert_eq!(h1, h2);
1048        assert!(h1.starts_with("fnv64:"));
1049        // Different inputs -> different hash
1050        assert_ne!(h1, signature_hash(&["a", "b", "d"]));
1051    }
1052
1053    #[test]
1054    fn policy_tightening_reduces_max_age() {
1055        let p = FreshnessPolicy::default();
1056        let tightened = p.clone().tightened(0.5);
1057        assert!(tightened.default_max_age_ms < p.default_max_age_ms);
1058        assert!(tightened.sensitive_max_age_ms < p.sensitive_max_age_ms);
1059    }
1060
1061    #[test]
1062    fn policy_class_overrides_win_over_heuristic() {
1063        let p = FreshnessPolicy::default()
1064            .with_domain_override("captcha.example", Some(DomainClass::Default))
1065            .with_domain_override("Friendly", Some(DomainClass::Hostile));
1066        // captcha would normally be Sensitive, but we overrode it to Default
1067        assert_eq!(p.class_for("captcha.example"), DomainClass::Default);
1068        // 'Friendly' would normally be Default but overridden to Hostile
1069        assert_eq!(p.class_for("friendly"), DomainClass::Hostile);
1070    }
1071
1072    #[test]
1073    fn contract_rejects_empty_domain() {
1074        let err = FreshnessContract::with_signature(
1075            "",
1076            "sha256:abc",
1077            CAPTURED_AT,
1078            Duration::from_secs(1),
1079            FreshnessPolicyKind::Standard,
1080        )
1081        .unwrap_err();
1082        assert!(matches!(err, FreshnessError::InvalidContract(_)));
1083    }
1084
1085    #[test]
1086    fn contract_rejects_zero_max_age() {
1087        let err = FreshnessContract::with_signature(
1088            "example.com",
1089            "sha256:abc",
1090            CAPTURED_AT,
1091            Duration::ZERO,
1092            FreshnessPolicyKind::Standard,
1093        )
1094        .unwrap_err();
1095        assert!(matches!(err, FreshnessError::InvalidContract(_)));
1096    }
1097
1098    #[test]
1099    fn report_logs_skip_when_no_contract() {
1100        let report = FreshnessReport::skipped(FreshnessPolicyKind::Standard, DomainClass::Default);
1101        assert!(report.decision.is_valid());
1102        assert!(!report.contract_evaluated);
1103    }
1104
1105    #[test]
1106    fn domain_class_label_is_stable() {
1107        assert_eq!(DomainClass::Default.label(), "default");
1108        assert_eq!(DomainClass::Hostile.label(), "hostile");
1109        assert_eq!(DomainClass::Authenticated.label(), "authenticated");
1110        assert_eq!(DomainClass::Sensitive.label(), "sensitive");
1111    }
1112
1113    #[test]
1114    fn json_roundtrip_preserves_contract() -> std::result::Result<(), Box<dyn std::error::Error>> {
1115        let c = contract(60_000, Some("sha256:abc"));
1116        let json = serde_json::to_string(&c)?;
1117        let back: FreshnessContract = serde_json::from_str(&json)?;
1118        assert_eq!(c, back);
1119        Ok(())
1120    }
1121}