stygian_browser/integrity_canary/
trend.rs1use std::fmt;
24
25use serde::{Deserialize, Serialize};
26
27use crate::freshness::signature_hash;
28use crate::integrity_canary::report::{
29 IntegrityCanaryReport, IntegrityRiskClassification, IntegrityRiskScore,
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum TrendSeverity {
42 Clean,
44 Suspected,
47 Confirmed,
50}
51
52impl TrendSeverity {
53 #[must_use]
55 pub const fn label(self) -> &'static str {
56 match self {
57 Self::Clean => "clean",
58 Self::Suspected => "suspected",
59 Self::Confirmed => "confirmed",
60 }
61 }
62
63 #[must_use]
65 pub const fn from_score(score: &IntegrityRiskScore) -> Self {
66 match score.classification {
67 IntegrityRiskClassification::Confirmed => Self::Confirmed,
68 IntegrityRiskClassification::Suspected => Self::Suspected,
69 IntegrityRiskClassification::Clean => Self::Clean,
70 }
71 }
72}
73
74impl fmt::Display for TrendSeverity {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(self.label())
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct CanaryTrendObservation {
108 pub signature: String,
113 pub score: f64,
115 pub severity: TrendSeverity,
117 pub contributing_findings: usize,
119 pub skipped_findings: usize,
121 pub trap_count: usize,
123 pub confirmed_count: usize,
125 pub fired_probe_ids: Vec<String>,
128 pub captured_at_epoch_ms: u64,
132}
133
134impl CanaryTrendObservation {
135 #[must_use]
142 pub fn from_report(report: &IntegrityCanaryReport) -> Self {
143 let fired_probe_ids: Vec<String> =
144 report.trap_findings.iter().map(|f| f.id.clone()).collect();
145
146 let mut signature_parts: Vec<String> = Vec::with_capacity(report.findings.len() * 3);
147 for f in &report.findings {
148 signature_parts.push(f.id.clone());
149 signature_parts.push(f.outcome.label().to_string());
150 signature_parts.push(format!("{:.6}", f.weight));
151 }
152 let borrowed: Vec<&str> = signature_parts.iter().map(String::as_str).collect();
153 let signature = signature_hash(&borrowed);
154
155 Self {
156 signature,
157 score: report.score.value,
158 severity: TrendSeverity::from_score(&report.score),
159 contributing_findings: report.score.contributing_findings,
160 skipped_findings: report.score.skipped_findings,
161 trap_count: report.trap_count(),
162 confirmed_count: report.confirmed_count(),
163 fired_probe_ids,
164 captured_at_epoch_ms: crate::freshness::unix_epoch_ms(),
165 }
166 }
167
168 #[must_use]
171 pub const fn has_trap_signal(&self) -> bool {
172 !matches!(self.severity, TrendSeverity::Clean)
173 }
174
175 #[must_use]
177 pub const fn is_confirmed(&self) -> bool {
178 matches!(self.severity, TrendSeverity::Confirmed)
179 }
180}
181
182#[cfg(test)]
185#[allow(
186 clippy::unwrap_used,
187 clippy::expect_used,
188 clippy::panic,
189 clippy::indexing_slicing
190)]
191mod tests {
192 use super::*;
193 use crate::integrity_canary::probes::{IntegrityProbe, IntegrityProbeOutcome, ProbeFinding};
194 use crate::integrity_canary::report::{
195 IntegrityCanaryPolicy, IntegrityCanaryReport, IntegrityRiskClassification,
196 };
197
198 #[test]
199 fn observation_signature_is_deterministic_for_same_findings() {
200 let report = IntegrityCanaryReport::from_findings(vec![
201 IntegrityProbe::confirmed_finding("a", 0.5, "x"),
202 IntegrityProbe::confirmed_finding("b", 0.5, "y"),
203 ]);
204 let obs_a = CanaryTrendObservation::from_report(&report);
205 let obs_b = CanaryTrendObservation::from_report(&report);
206 assert_eq!(obs_a.signature, obs_b.signature);
209 assert!((obs_a.score - obs_b.score).abs() < 1e-9);
210 }
211
212 #[test]
213 fn observation_signature_changes_with_finding_set() {
214 let report_a =
215 IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
216 "a", 0.5, "x",
217 )]);
218 let report_b =
219 IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
220 "b", 0.5, "y",
221 )]);
222 let obs_a = CanaryTrendObservation::from_report(&report_a);
223 let obs_b = CanaryTrendObservation::from_report(&report_b);
224 assert_ne!(obs_a.signature, obs_b.signature);
225 }
226
227 #[test]
228 fn observation_severity_tracks_classification() {
229 let report = IntegrityCanaryReport::from_findings(Vec::new());
231 let obs = CanaryTrendObservation::from_report(&report);
232 assert_eq!(obs.severity, TrendSeverity::Clean);
233 assert!(!obs.has_trap_signal());
234 assert!(!obs.is_confirmed());
235
236 let report = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
238 "a", 1.0, "x",
239 )]);
240 let obs = CanaryTrendObservation::from_report(&report);
241 assert_eq!(obs.severity, TrendSeverity::Confirmed);
242 assert!(obs.has_trap_signal());
243 assert!(obs.is_confirmed());
244 }
245
246 #[test]
247 fn observation_carries_fired_probe_ids_in_evaluation_order() {
248 let findings = vec![
249 ProbeFinding {
250 id: "probe_one".to_string(),
251 outcome: IntegrityProbeOutcome::TrapConfirmed,
252 weight: 0.20,
253 evidence: "x".to_string(),
254 mitigation_hint: String::new(),
255 },
256 ProbeFinding {
257 id: "probe_two".to_string(),
258 outcome: IntegrityProbeOutcome::TrapSuspected,
259 weight: 0.15,
260 evidence: "y".to_string(),
261 mitigation_hint: String::new(),
262 },
263 ProbeFinding {
264 id: "probe_three".to_string(),
265 outcome: IntegrityProbeOutcome::Clean,
266 weight: 0.10,
267 evidence: "z".to_string(),
268 mitigation_hint: String::new(),
269 },
270 ];
271 let report = IntegrityCanaryReport::from_findings(findings);
272 let obs = CanaryTrendObservation::from_report(&report);
273 assert_eq!(
274 obs.fired_probe_ids,
275 vec!["probe_one".to_string(), "probe_two".to_string()]
276 );
277 assert_eq!(obs.confirmed_count, 1);
278 assert_eq!(obs.trap_count, 2);
279 }
280
281 #[test]
282 fn observation_skipped_count_reflects_findings() {
283 let findings = vec![
284 ProbeFinding {
285 id: "a".to_string(),
286 outcome: IntegrityProbeOutcome::Skipped,
287 weight: 0.5,
288 evidence: "x".to_string(),
289 mitigation_hint: String::new(),
290 },
291 ProbeFinding {
292 id: "b".to_string(),
293 outcome: IntegrityProbeOutcome::TrapConfirmed,
294 weight: 0.5,
295 evidence: "y".to_string(),
296 mitigation_hint: String::new(),
297 },
298 ];
299 let report = IntegrityCanaryReport::from_findings(findings);
300 let obs = CanaryTrendObservation::from_report(&report);
301 assert_eq!(obs.skipped_findings, 1);
302 assert_eq!(obs.contributing_findings, 1);
303 assert_eq!(obs.confirmed_count, 1);
304 }
305
306 #[test]
307 fn observation_handles_strict_thresholds() {
308 let policy = IntegrityCanaryPolicy::try_with_thresholds(0.10, 0.20).expect("policy");
309 let findings = vec![ProbeFinding {
310 id: "a".to_string(),
311 outcome: IntegrityProbeOutcome::TrapSuspected,
312 weight: 1.0,
313 evidence: "x".to_string(),
314 mitigation_hint: String::new(),
315 }];
316 let report = IntegrityCanaryReport::with_policy(findings, policy);
317 let obs = CanaryTrendObservation::from_report(&report);
318 assert_eq!(obs.severity, TrendSeverity::Confirmed);
320 }
321
322 #[test]
323 fn observation_serializes_with_snake_case_keys() {
324 let report = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
325 "a", 0.5, "x",
326 )]);
327 let obs = CanaryTrendObservation::from_report(&report);
328 let json = serde_json::to_string(&obs).expect("serialize");
329 assert!(json.contains("\"signature\""), "got: {json}");
330 assert!(json.contains("\"score\""), "got: {json}");
331 assert!(json.contains("\"severity\""), "got: {json}");
332 assert!(json.contains("\"trap_count\""), "got: {json}");
333 assert!(json.contains("\"confirmed_count\""), "got: {json}");
334 assert!(json.contains("\"fired_probe_ids\""), "got: {json}");
335 assert!(json.contains("\"captured_at_epoch_ms\""), "got: {json}");
336 }
337
338 #[test]
339 fn trend_severity_labels_are_stable() {
340 assert_eq!(TrendSeverity::Clean.label(), "clean");
341 assert_eq!(TrendSeverity::Suspected.label(), "suspected");
342 assert_eq!(TrendSeverity::Confirmed.label(), "confirmed");
343 }
344
345 #[test]
346 fn trend_severity_from_score_round_trips_classification() {
347 let mut score = IntegrityRiskScore::clean();
348 assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Clean);
349 score.classification = IntegrityRiskClassification::Suspected;
350 assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Suspected);
351 score.classification = IntegrityRiskClassification::Confirmed;
352 assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Confirmed);
353 }
354}