Skip to main content

parlov_core/
outcome.rs

1//! Per-strategy outcome type for endpoint-level aggregation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::OracleResult;
6
7/// The outcome of running a single strategy, classified for aggregation.
8///
9/// Each variant encodes the raw `OracleResult` (where applicable) and the weight instruction
10/// to the aggregator.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub enum StrategyOutcome {
13    /// Strategy observed a differential supporting existence.
14    ///
15    /// The inner `OracleResult` carries the confidence score, signals, and severity. Contributes
16    /// positively to the aggregated verdict.
17    Positive(OracleResult),
18    /// Strategy ran but produced no actionable differential.
19    ///
20    /// Includes: technique didn't fire, server normalised responses uniformly, or responses were
21    /// unstable across samples. Contributes zero evidence to aggregation.
22    NoSignal(OracleResult),
23    /// Strategy observed a response pattern that actively supports nonexistence.
24    ///
25    /// The `f32` is the contradiction weight in `[0.0, 1.0]`. A weight of `1.0` means the server
26    /// is definitively normalised; `0.0` is equivalent to `NoSignal`. Contributes negatively to
27    /// the aggregated verdict proportional to the weight.
28    Contradictory(OracleResult, f32),
29    /// Strategy could not run — missing prerequisites or inapplicable context.
30    ///
31    /// The inner string is a human-readable reason, e.g. `"target does not support ETags"`.
32    /// Contributes zero evidence to aggregation.
33    Inapplicable(std::borrow::Cow<'static, str>),
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use crate::{
40        NormativeStrength, OracleClass, OracleVerdict, Severity, Signal, SignalKind, Vector,
41    };
42
43    fn positive_result() -> OracleResult {
44        OracleResult {
45            class: OracleClass::Existence,
46            verdict: OracleVerdict::Confirmed,
47            severity: Some(Severity::High),
48            confidence: 85,
49            impact_class: None,
50            reasons: vec![],
51            signals: vec![Signal {
52                kind: SignalKind::StatusCodeDiff,
53                evidence: "403 (baseline) vs 404 (probe)".into(),
54                rfc_basis: Some("RFC 9110 §15.5.4".into()),
55            }],
56            technique_id: Some("get-200-404".into()),
57            vector: Some(Vector::StatusCodeDiff),
58            normative_strength: Some(NormativeStrength::Must),
59            label: Some("Authorization-based differential".into()),
60            leaks: Some("Resource existence confirmed to low-privilege callers".into()),
61            rfc_basis: Some("RFC 9110 §15.5.4".into()),
62        }
63    }
64
65    fn no_signal_result() -> OracleResult {
66        OracleResult {
67            class: OracleClass::Existence,
68            verdict: OracleVerdict::NotPresent,
69            severity: None,
70            confidence: 0,
71            impact_class: None,
72            reasons: vec![],
73            signals: vec![Signal {
74                kind: SignalKind::StatusCodeDiff,
75                evidence: "404 (baseline) vs 404 (probe)".into(),
76                rfc_basis: None,
77            }],
78            technique_id: Some("get-200-404".into()),
79            vector: Some(Vector::StatusCodeDiff),
80            normative_strength: Some(NormativeStrength::Must),
81            label: None,
82            leaks: None,
83            rfc_basis: None,
84        }
85    }
86
87    fn contradictory_result() -> OracleResult {
88        OracleResult {
89            class: OracleClass::Existence,
90            verdict: OracleVerdict::NotPresent,
91            severity: None,
92            confidence: 0,
93            impact_class: None,
94            reasons: vec![],
95            signals: vec![Signal {
96                kind: SignalKind::StatusCodeDiff,
97                evidence: "200 (baseline) vs 200 (probe) — server normalises".into(),
98                rfc_basis: None,
99            }],
100            technique_id: Some("if-none-match".into()),
101            vector: Some(Vector::CacheProbing),
102            normative_strength: Some(NormativeStrength::Should),
103            label: None,
104            leaks: None,
105            rfc_basis: None,
106        }
107    }
108
109    #[test]
110    fn roundtrip_positive_inner_oracle_result_survives() {
111        let outcome = StrategyOutcome::Positive(positive_result());
112        let json = serde_json::to_string(&outcome).expect("serialization failed");
113        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
114        let StrategyOutcome::Positive(result) = back else {
115            panic!("expected Positive variant after roundtrip");
116        };
117        assert_eq!(result.verdict, OracleVerdict::Confirmed);
118        assert_eq!(result.confidence, 85);
119        assert_eq!(result.technique_id.as_deref(), Some("get-200-404"));
120        assert_eq!(result.signals.len(), 1);
121        assert_eq!(result.signals[0].kind, SignalKind::StatusCodeDiff);
122        assert_eq!(result.signals[0].evidence, "403 (baseline) vs 404 (probe)");
123        assert_eq!(
124            result.label.as_deref(),
125            Some("Authorization-based differential")
126        );
127    }
128
129    #[test]
130    fn roundtrip_no_signal_inner_oracle_result_survives() {
131        let outcome = StrategyOutcome::NoSignal(no_signal_result());
132        let json = serde_json::to_string(&outcome).expect("serialization failed");
133        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
134        let StrategyOutcome::NoSignal(result) = back else {
135            panic!("expected NoSignal variant after roundtrip");
136        };
137        assert_eq!(result.verdict, OracleVerdict::NotPresent);
138        assert_eq!(result.confidence, 0);
139        assert!(result.label.is_none());
140        assert!(result.leaks.is_none());
141    }
142
143    #[test]
144    fn roundtrip_contradictory_weight_survives() {
145        let weight: f32 = 0.75;
146        let outcome = StrategyOutcome::Contradictory(contradictory_result(), weight);
147        let json = serde_json::to_string(&outcome).expect("serialization failed");
148        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
149        let StrategyOutcome::Contradictory(result, back_weight) = back else {
150            panic!("expected Contradictory variant after roundtrip");
151        };
152        assert_eq!(result.verdict, OracleVerdict::NotPresent);
153        assert!((back_weight - weight).abs() < f32::EPSILON);
154        assert_eq!(result.technique_id.as_deref(), Some("if-none-match"));
155    }
156
157    #[test]
158    fn roundtrip_inapplicable_reason_string_survives() {
159        let reason = "target does not support ETags".to_string();
160        let outcome = StrategyOutcome::Inapplicable(std::borrow::Cow::Owned(reason.clone()));
161        let json = serde_json::to_string(&outcome).expect("serialization failed");
162        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
163        let StrategyOutcome::Inapplicable(back_reason) = back else {
164            panic!("expected Inapplicable variant after roundtrip");
165        };
166        assert_eq!(back_reason, reason);
167    }
168
169    #[test]
170    fn clone_positive() {
171        let outcome = StrategyOutcome::Positive(positive_result());
172        let cloned = outcome.clone();
173        let StrategyOutcome::Positive(result) = cloned else {
174            panic!("expected Positive after clone");
175        };
176        assert_eq!(result.confidence, 85);
177    }
178
179    #[test]
180    fn clone_no_signal() {
181        let outcome = StrategyOutcome::NoSignal(no_signal_result());
182        let cloned = outcome.clone();
183        let StrategyOutcome::NoSignal(result) = cloned else {
184            panic!("expected NoSignal after clone");
185        };
186        assert_eq!(result.verdict, OracleVerdict::NotPresent);
187    }
188
189    #[test]
190    fn clone_contradictory() {
191        let outcome = StrategyOutcome::Contradictory(contradictory_result(), 0.5);
192        let cloned = outcome.clone();
193        let StrategyOutcome::Contradictory(result, w) = cloned else {
194            panic!("expected Contradictory after clone");
195        };
196        assert_eq!(result.verdict, OracleVerdict::NotPresent);
197        assert!((w - 0.5_f32).abs() < f32::EPSILON);
198    }
199
200    #[test]
201    fn clone_inapplicable() {
202        let outcome = StrategyOutcome::Inapplicable(std::borrow::Cow::Borrowed("no ETag support"));
203        let cloned = outcome.clone();
204        let StrategyOutcome::Inapplicable(reason) = cloned else {
205            panic!("expected Inapplicable after clone");
206        };
207        assert_eq!(reason, "no ETag support");
208    }
209}