use std::fmt;
use serde::{Deserialize, Serialize};
use crate::integrity_canary::probes::ProbeFinding;
pub const RISK_SUSPECTED_THRESHOLD_DEFAULT: f64 = 0.30;
pub const RISK_CONFIRMED_THRESHOLD_DEFAULT: f64 = 0.65;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrityRiskClassification {
Clean,
Suspected,
Confirmed,
}
impl IntegrityRiskClassification {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Clean => "clean",
Self::Suspected => "suspected",
Self::Confirmed => "confirmed",
}
}
}
impl fmt::Display for IntegrityRiskClassification {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct IntegrityRiskScore {
pub value: f64,
pub classification: IntegrityRiskClassification,
pub contributing_findings: usize,
pub skipped_findings: usize,
}
impl IntegrityRiskScore {
#[must_use]
pub const fn clean() -> Self {
Self {
value: 0.0,
classification: IntegrityRiskClassification::Clean,
contributing_findings: 0,
skipped_findings: 0,
}
}
#[must_use]
pub const fn value(&self) -> f64 {
self.value
}
#[must_use]
pub const fn classification(&self) -> IntegrityRiskClassification {
self.classification
}
#[must_use]
pub const fn is_trap_signal(&self) -> bool {
!matches!(self.classification, IntegrityRiskClassification::Clean)
}
#[must_use]
pub const fn is_confirmed(&self) -> bool {
matches!(self.classification, IntegrityRiskClassification::Confirmed)
}
#[must_use]
pub fn compute(findings: &[ProbeFinding], policy: &IntegrityCanaryPolicy) -> Self {
let mut numerator = 0.0;
let mut denominator = 0.0;
let mut contributing = 0usize;
let mut skipped = 0usize;
for f in findings {
if f.outcome.contributes() {
numerator = f.weight.mul_add(f.outcome.severity(), numerator);
denominator += f.weight;
contributing += 1;
} else {
skipped += 1;
}
}
let raw = if denominator <= 0.0 {
0.0
} else {
numerator / denominator
};
let value = clamp_unit(raw);
let classification = policy.classify(value);
Self {
value,
classification,
contributing_findings: contributing,
skipped_findings: skipped,
}
}
#[must_use]
pub fn classify_for(value: f64, policy: &IntegrityCanaryPolicy) -> IntegrityRiskClassification {
policy.classify(value)
}
}
const fn clamp_unit(value: f64) -> f64 {
if value.is_nan() {
return 0.0;
}
value.clamp(0.0, 1.0)
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct IntegrityCanaryPolicy {
pub suspected_threshold: f64,
pub confirmed_threshold: f64,
}
impl Default for IntegrityCanaryPolicy {
fn default() -> Self {
Self {
suspected_threshold: RISK_SUSPECTED_THRESHOLD_DEFAULT,
confirmed_threshold: RISK_CONFIRMED_THRESHOLD_DEFAULT,
}
}
}
impl IntegrityCanaryPolicy {
#[must_use]
pub fn with_thresholds(suspected_threshold: f64, confirmed_threshold: f64) -> Self {
#[allow(clippy::expect_used)]
Self::try_with_thresholds(suspected_threshold, confirmed_threshold)
.expect("integrity canary thresholds must be finite and strictly ordered")
}
pub fn try_with_thresholds(
suspected_threshold: f64,
confirmed_threshold: f64,
) -> Result<Self, IntegrityCanaryPolicyError> {
if suspected_threshold.is_nan() || confirmed_threshold.is_nan() {
return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
"thresholds must be finite (suspected={suspected_threshold}, confirmed={confirmed_threshold})"
)));
}
match suspected_threshold.partial_cmp(&confirmed_threshold) {
Some(std::cmp::Ordering::Less) => {}
_ => {
return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
"suspected_threshold ({suspected_threshold}) must be strictly less than confirmed_threshold ({confirmed_threshold})"
)));
}
}
Ok(Self {
suspected_threshold: clamp_unit(suspected_threshold),
confirmed_threshold: clamp_unit(confirmed_threshold),
})
}
#[must_use]
pub fn classify(&self, value: f64) -> IntegrityRiskClassification {
let v = clamp_unit(value);
if v.is_nan() {
return IntegrityRiskClassification::Clean;
}
if v >= self.confirmed_threshold {
IntegrityRiskClassification::Confirmed
} else if v >= self.suspected_threshold {
IntegrityRiskClassification::Suspected
} else {
IntegrityRiskClassification::Clean
}
}
pub fn validate(&self) -> Result<(), IntegrityCanaryPolicyError> {
if self.suspected_threshold.is_nan() || self.confirmed_threshold.is_nan() {
return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
"thresholds must be finite (suspected={}, confirmed={})",
self.suspected_threshold, self.confirmed_threshold
)));
}
match self
.suspected_threshold
.partial_cmp(&self.confirmed_threshold)
{
Some(std::cmp::Ordering::Less) => Ok(()),
_ => Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
"suspected_threshold ({}) must be strictly less than confirmed_threshold ({})",
self.suspected_threshold, self.confirmed_threshold
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntegrityCanaryPolicyError {
InvalidThresholds(String),
}
impl fmt::Display for IntegrityCanaryPolicyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidThresholds(msg) => {
write!(f, "integrity canary thresholds invalid: {msg}")
}
}
}
}
impl std::error::Error for IntegrityCanaryPolicyError {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntegrityCanaryReport {
pub score: IntegrityRiskScore,
pub policy: IntegrityCanaryPolicy,
pub findings: Vec<ProbeFinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mitigation_hints: Vec<MitigationHint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trap_findings: Vec<ProbeFinding>,
}
impl IntegrityCanaryReport {
#[must_use]
pub fn from_findings(findings: Vec<ProbeFinding>) -> Self {
Self::with_policy(findings, IntegrityCanaryPolicy::default())
}
#[must_use]
pub fn with_policy(findings: Vec<ProbeFinding>, policy: IntegrityCanaryPolicy) -> Self {
let score = IntegrityRiskScore::compute(&findings, &policy);
let trap_findings: Vec<ProbeFinding> =
findings.iter().filter(|f| f.is_trap()).cloned().collect();
let mitigation_hints: Vec<MitigationHint> = trap_findings
.iter()
.filter(|f| !f.mitigation_hint.is_empty())
.map(|f| MitigationHint {
probe_id: f.id.clone(),
outcome: f.outcome,
hint: f.mitigation_hint.clone(),
})
.collect();
Self {
score,
policy,
findings,
mitigation_hints,
trap_findings,
}
}
#[must_use]
pub const fn is_confirmed(&self) -> bool {
self.score.is_confirmed()
}
#[must_use]
pub const fn has_trap_signal(&self) -> bool {
self.score.is_trap_signal()
}
#[must_use]
pub const fn trap_count(&self) -> usize {
self.trap_findings.len()
}
#[must_use]
pub fn confirmed_count(&self) -> usize {
self.findings.iter().filter(|f| f.is_confirmed()).count()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MitigationHint {
pub probe_id: String,
pub outcome: crate::integrity_canary::probes::IntegrityProbeOutcome,
pub hint: String,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use crate::integrity_canary::probes::{
IntegrityProbe, IntegrityProbeId, IntegrityProbeOutcome, ProbeFinding, all_probes,
};
fn finding(id: &str, weight: f64, outcome: IntegrityProbeOutcome, hint: &str) -> ProbeFinding {
ProbeFinding {
id: id.to_string(),
outcome,
weight,
evidence: "test".to_string(),
mitigation_hint: hint.to_string(),
}
}
fn trap_finding(id: &str, weight: f64) -> ProbeFinding {
finding(id, weight, IntegrityProbeOutcome::TrapConfirmed, "hint")
}
fn suspected_finding(id: &str, weight: f64) -> ProbeFinding {
finding(id, weight, IntegrityProbeOutcome::TrapSuspected, "hint")
}
#[test]
fn empty_findings_produces_clean_score() {
let report = IntegrityCanaryReport::from_findings(Vec::new());
assert_eq!(report.score.value, 0.0);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Clean
);
assert!(!report.has_trap_signal());
assert!(report.mitigation_hints.is_empty());
assert!(report.trap_findings.is_empty());
}
#[test]
fn all_clean_findings_produces_zero_score() {
let findings = all_probes()
.iter()
.map(|p| finding(p.id.label(), p.weight, IntegrityProbeOutcome::Clean, ""))
.collect();
let report = IntegrityCanaryReport::from_findings(findings);
assert_eq!(report.score.value, 0.0);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Clean
);
assert_eq!(report.score.contributing_findings, 8);
assert_eq!(report.score.skipped_findings, 0);
}
#[test]
fn all_confirmed_findings_produces_full_score() {
let findings = all_probes()
.iter()
.map(|p| {
finding(
p.id.label(),
p.weight,
IntegrityProbeOutcome::TrapConfirmed,
"",
)
})
.collect();
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 1.0).abs() < 1e-9,
"score must be 1.0, got: {}",
report.score.value
);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Confirmed
);
assert_eq!(report.confirmed_count(), 8);
assert!(report.is_confirmed());
}
#[test]
fn mixed_outcomes_weighted_average() {
let mut findings = Vec::new();
for (i, p) in all_probes().iter().enumerate() {
if i < 4 {
findings.push(finding(
p.id.label(),
p.weight,
IntegrityProbeOutcome::Clean,
"",
));
} else {
findings.push(trap_finding(p.id.label(), p.weight));
}
}
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 0.44).abs() < 1e-6,
"4 clean + 4 confirmed: score must be 0.44, got: {}",
report.score.value
);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Suspected
);
}
#[test]
fn suspected_findings_yield_half_score() {
let findings = all_probes()
.iter()
.map(|p| suspected_finding(p.id.label(), p.weight))
.collect();
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 0.5).abs() < 1e-9,
"all-suspected must be 0.5, got: {}",
report.score.value
);
}
#[test]
fn single_confirmed_on_largest_probe_gives_weighted_score() {
let mut findings = Vec::new();
for (i, p) in all_probes().iter().enumerate() {
if i == 0 {
findings.push(trap_finding(p.id.label(), p.weight));
} else {
findings.push(finding(
p.id.label(),
p.weight,
IntegrityProbeOutcome::Clean,
"",
));
}
}
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 0.20).abs() < 1e-9,
"single confirmed trap on the largest-weight probe: score must be 0.20, got: {}",
report.score.value
);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Clean,
"single confirmed trap on the largest-weight probe must remain Clean (below 0.30)"
);
assert_eq!(report.confirmed_count(), 1);
}
#[test]
fn skipped_findings_excluded_from_denominator() {
let mut findings = Vec::new();
let mut weights_kept = 0.0;
for (i, p) in all_probes().iter().enumerate() {
if i < 4 {
findings.push(trap_finding(p.id.label(), p.weight));
weights_kept += p.weight;
} else {
findings.push(finding(
p.id.label(),
p.weight,
IntegrityProbeOutcome::Skipped,
"",
));
}
}
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 1.0).abs() < 1e-9,
"skipped findings must not pull score toward 0, got: {}",
report.score.value
);
assert_eq!(report.score.skipped_findings, 4);
assert_eq!(report.score.contributing_findings, 4);
let _ = weights_kept; }
#[test]
fn threshold_distinguishes_suspected_from_confirmed() {
let findings = vec![
finding("a", 0.125, IntegrityProbeOutcome::Clean, ""),
finding("b", 0.125, IntegrityProbeOutcome::Clean, ""),
finding("c", 0.125, IntegrityProbeOutcome::Clean, ""),
finding("d", 0.125, IntegrityProbeOutcome::Clean, ""),
suspected_finding("e", 0.125),
suspected_finding("f", 0.125),
suspected_finding("g", 0.125),
suspected_finding("h", 0.125),
];
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 0.25).abs() < 1e-9,
"score must be 0.25, got: {}",
report.score.value
);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Clean
);
let findings = vec![
finding("a", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
finding("b", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
finding("c", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
finding("d", 0.125, IntegrityProbeOutcome::Clean, ""),
suspected_finding("e", 0.125),
suspected_finding("f", 0.125),
suspected_finding("g", 0.125),
suspected_finding("h", 0.125),
];
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 0.625).abs() < 1e-9,
"score must be 0.625, got: {}",
report.score.value
);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Suspected,
"0.625 must be Suspected (above 0.30, below 0.65)"
);
let findings = vec![
finding("a", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
finding("b", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
finding("c", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
finding("d", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
suspected_finding("e", 0.125),
suspected_finding("f", 0.125),
suspected_finding("g", 0.125),
suspected_finding("h", 0.125),
];
let report = IntegrityCanaryReport::from_findings(findings);
assert!(
(report.score.value - 0.75).abs() < 1e-9,
"score must be 0.75, got: {}",
report.score.value
);
assert_eq!(
report.score.classification,
IntegrityRiskClassification::Confirmed,
"0.75 must be Confirmed (above 0.65)"
);
}
#[test]
fn policy_with_lower_thresholds_tightens_classification() {
let policy = IntegrityCanaryPolicy::try_with_thresholds(0.10, 0.40).expect("policy");
let findings = vec![finding("a", 1.0, IntegrityProbeOutcome::Clean, "")];
let score = IntegrityRiskScore::compute(&findings, &policy);
assert_eq!(score.value, 0.0);
assert_eq!(score.classification, IntegrityRiskClassification::Clean);
let findings = vec![finding("a", 1.0, IntegrityProbeOutcome::TrapSuspected, "")];
let score = IntegrityRiskScore::compute(&findings, &policy);
assert!((score.value - 0.5).abs() < 1e-9);
assert_eq!(score.classification, IntegrityRiskClassification::Confirmed);
}
#[test]
fn policy_rejects_reversed_or_equal_thresholds() {
let err = IntegrityCanaryPolicy::try_with_thresholds(0.50, 0.50).unwrap_err();
assert!(matches!(
err,
IntegrityCanaryPolicyError::InvalidThresholds(_)
));
let err = IntegrityCanaryPolicy::try_with_thresholds(0.70, 0.30).unwrap_err();
assert!(matches!(
err,
IntegrityCanaryPolicyError::InvalidThresholds(_)
));
}
#[test]
fn policy_rejects_nan_thresholds() {
let err = IntegrityCanaryPolicy::try_with_thresholds(f64::NAN, 0.65).unwrap_err();
assert!(matches!(
err,
IntegrityCanaryPolicyError::InvalidThresholds(_)
));
let err = IntegrityCanaryPolicy::try_with_thresholds(0.30, f64::NAN).unwrap_err();
assert!(matches!(
err,
IntegrityCanaryPolicyError::InvalidThresholds(_)
));
}
#[test]
fn trap_findings_and_hints_populated_for_fired_probes() {
let findings = vec![
trap_finding("webdriver_descriptor_native", 0.20),
ProbeFinding {
id: "performance_now_resolution".to_string(),
outcome: IntegrityProbeOutcome::TrapSuspected,
weight: 0.14,
evidence: "deviation detected".to_string(),
mitigation_hint: "Apply continuous jitter".to_string(),
},
finding(
"error_to_string_native",
0.08,
IntegrityProbeOutcome::Clean,
"",
),
];
let report = IntegrityCanaryReport::from_findings(findings);
assert_eq!(report.trap_count(), 2);
assert_eq!(report.confirmed_count(), 1);
assert_eq!(report.mitigation_hints.len(), 2);
let clean_hint = report
.mitigation_hints
.iter()
.find(|h| h.probe_id == "error_to_string_native");
assert!(clean_hint.is_none());
}
#[test]
fn nan_score_classifies_as_clean() {
let policy = IntegrityCanaryPolicy::default();
assert_eq!(
IntegrityRiskScore::classify_for(f64::NAN, &policy),
IntegrityRiskClassification::Clean
);
assert_eq!(
policy.classify(f64::NAN),
IntegrityRiskClassification::Clean
);
}
#[test]
fn score_outside_unit_interval_is_clamped() {
let policy = IntegrityCanaryPolicy::default();
assert_eq!(policy.classify(1.5), IntegrityRiskClassification::Confirmed);
assert_eq!(policy.classify(-0.5), IntegrityRiskClassification::Clean);
}
#[test]
fn confirmed_finding_helper_attaches_hint() {
let probe = IntegrityProbe::confirmed_finding("test_probe", 0.10, "evidence");
let report = IntegrityCanaryReport::from_findings(vec![probe]);
assert!(report.is_confirmed());
assert_eq!(report.trap_count(), 1);
}
#[test]
fn probe_id_label_is_stable_for_trend_seam() {
let id = IntegrityProbeId::WebDriverDescriptorNative;
assert_eq!(id.label(), "webdriver_descriptor_native");
}
#[test]
fn mitigation_hints_carry_outcome_label() {
let findings = vec![ProbeFinding {
id: "test".to_string(),
outcome: IntegrityProbeOutcome::TrapConfirmed,
weight: 0.20,
evidence: "x".to_string(),
mitigation_hint: "apply native descriptor".to_string(),
}];
let report = IntegrityCanaryReport::from_findings(findings);
assert_eq!(report.mitigation_hints.len(), 1);
assert_eq!(
report.mitigation_hints[0].outcome,
IntegrityProbeOutcome::TrapConfirmed
);
assert_eq!(report.mitigation_hints[0].probe_id, "test");
}
#[test]
fn report_serializes_with_snake_case_keys() {
let report = IntegrityCanaryReport::from_findings(vec![trap_finding("a", 1.0)]);
let json = serde_json::to_string(&report).expect("serialize");
assert!(json.contains("\"score\""), "got: {json}");
assert!(json.contains("\"classification\""), "got: {json}");
assert!(json.contains("\"contributing_findings\""), "got: {json}");
assert!(json.contains("\"skipped_findings\""), "got: {json}");
assert!(json.contains("\"findings\""), "got: {json}");
assert!(json.contains("\"trap_findings\""), "got: {json}");
assert!(json.contains("\"mitigation_hints\""), "got: {json}");
assert!(json.contains("\"suspected_threshold\""), "got: {json}");
assert!(json.contains("\"confirmed_threshold\""), "got: {json}");
}
#[test]
fn report_roundtrips_through_json() {
let findings = vec![
trap_finding("a", 0.5),
finding("b", 0.5, IntegrityProbeOutcome::Clean, ""),
];
let report = IntegrityCanaryReport::from_findings(findings);
let json = serde_json::to_string(&report).expect("serialize");
let restored: IntegrityCanaryReport = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored, report);
}
#[test]
fn empty_report_omits_helper_fields_in_json() {
let report = IntegrityCanaryReport::from_findings(Vec::new());
let json = serde_json::to_string(&report).expect("serialize");
assert!(
!json.contains("mitigation_hints"),
"empty report must omit mitigation_hints: {json}"
);
assert!(
!json.contains("trap_findings"),
"empty report must omit trap_findings: {json}"
);
}
}