1use std::collections::HashMap;
67use std::fmt;
68use std::time::{Duration, SystemTime, UNIX_EPOCH};
69
70use serde::{Deserialize, Serialize};
71use thiserror::Error;
72
73#[derive(Debug, Error)]
77pub enum FreshnessError {
78 #[error("failed to (de)serialise freshness contract: {0}")]
80 Serialization(String),
81 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum FreshnessPolicyKind {
102 Strict,
104 Standard,
106 Permissive,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum DomainClass {
117 Default,
119 Hostile,
121 Authenticated,
123 Sensitive,
126}
127
128impl DomainClass {
129 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct FreshnessPolicy {
163 pub kind: FreshnessPolicyKind,
165 pub domain_class_overrides: HashMap<String, DomainClass>,
167 pub default_max_age_ms: u64,
169 pub hostile_max_age_ms: u64,
171 pub authenticated_max_age_ms: u64,
173 pub sensitive_max_age_ms: u64,
175 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 #[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 #[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 #[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 #[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 #[must_use]
247 pub const fn with_signature_required(mut self, required: bool) -> Self {
248 self.signature_required = required;
249 self
250 }
251
252 #[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 #[must_use]
270 pub fn for_domain(&self, host: &str) -> DomainClass {
271 self.class_for(host)
272 }
273
274 #[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 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct FreshnessContract {
377 pub domain: String,
379 pub signature_hash: Option<String>,
382 pub captured_at_epoch_ms: u64,
384 #[serde(with = "duration_ms")]
386 pub max_age: Duration,
387 pub policy_kind: FreshnessPolicyKind,
389 pub domain_class: DomainClass,
391}
392
393impl FreshnessContract {
394 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 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 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 #[must_use]
471 #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
472 pub const fn max_age_ms(&self) -> u64 {
473 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
483mod 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512pub struct InvalidationReason {
513 pub contract_domain: String,
515 pub observed_domain: String,
517 pub contract_signature: Option<String>,
519 pub observed_signature: Option<String>,
521 pub captured_at_epoch_ms: u64,
523 pub observed_at_epoch_ms: u64,
525 pub elapsed_ms: u64,
527 pub max_age_ms: u64,
529 pub policy_kind: FreshnessPolicyKind,
531 pub domain_class: DomainClass,
533 pub kind: InvalidationKind,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
539#[serde(rename_all = "snake_case")]
540pub enum InvalidationKind {
541 StaleTtl,
543 SignatureMismatch,
545 DomainMismatch,
547 SignatureMissing,
549}
550
551impl InvalidationKind {
552 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(tag = "outcome", rename_all = "snake_case")]
573pub enum FreshnessDecision {
574 Valid,
576 StaleTtl {
578 reason: Box<InvalidationReason>,
580 },
581 SignatureMismatch {
583 reason: Box<InvalidationReason>,
585 },
586 DomainMismatch {
588 reason: Box<InvalidationReason>,
590 },
591}
592
593impl FreshnessDecision {
594 #[must_use]
596 pub const fn is_valid(&self) -> bool {
597 matches!(self, Self::Valid)
598 }
599
600 #[must_use]
602 pub const fn is_invalid(&self) -> bool {
603 !self.is_valid()
604 }
605
606 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct FreshnessCheckInput {
648 pub observed_domain: String,
650 pub observed_signature: Option<String>,
652 pub observed_at_epoch_ms: u64,
654}
655
656impl FreshnessCheckInput {
657 #[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 #[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#[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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793pub struct FreshnessReport {
794 pub decision: FreshnessDecision,
796 pub domain_class: DomainClass,
798 pub policy_kind: FreshnessPolicyKind,
800 pub contract_evaluated: bool,
802}
803
804impl FreshnessReport {
805 #[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 #[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 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#[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#[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 hash ^= 0x1f;
918 hash = hash.wrapping_mul(PRIME);
919 }
920 format!("fnv64:{hash:016x}")
921}
922
923#[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 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 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 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 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 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 assert_eq!(p.class_for("captcha.example"), DomainClass::Default);
1068 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}