Skip to main content

parlov_analysis/existence/
analyzer.rs

1//! `ExistenceAnalyzer` — delegates classification to the pattern table in `classifier`.
2
3#![deny(clippy::all)]
4#![warn(clippy::pedantic)]
5
6use parlov_core::{OracleClass, OracleResult, OracleVerdict, ProbeSet, ResponseSurface};
7
8use crate::{Analyzer, SampleDecision};
9
10use super::classifier::classify;
11
12/// Analyzes a `ProbeSet` for GET existence oracle signals via status-code differential.
13///
14/// Requires three samples when a differential is detected to confirm stability before
15/// classifying. Short-circuits on same-status pairs (`NotPresent`) after the first sample.
16/// Delegates pattern classification to `classifier::classify`.
17pub struct ExistenceAnalyzer;
18
19impl Analyzer for ExistenceAnalyzer {
20    fn evaluate(&self, data: &ProbeSet) -> SampleDecision {
21        let b0 = data.baseline[0].status;
22        let p0 = data.probe[0].status;
23
24        if b0 == p0 {
25            return SampleDecision::Complete(classify(b0, p0));
26        }
27
28        let samples = data.baseline.len();
29        if samples < 3 {
30            return SampleDecision::NeedMore;
31        }
32
33        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
34        if stable {
35            SampleDecision::Complete(classify(b0, p0))
36        } else {
37            SampleDecision::Complete(unstable_result(&data.baseline, &data.probe))
38        }
39    }
40
41    fn oracle_class(&self) -> OracleClass {
42        OracleClass::Existence
43    }
44}
45
46/// Returns `true` when every surface in `surfaces` shares the same status as the first.
47fn is_consistent(surfaces: &[ResponseSurface]) -> bool {
48    surfaces.iter().all(|s| s.status == surfaces[0].status)
49}
50
51fn unstable_result(baseline: &[ResponseSurface], probe: &[ResponseSurface]) -> OracleResult {
52    let baseline_stable = is_consistent(baseline);
53    let probe_stable = is_consistent(probe);
54
55    let which = match (baseline_stable, probe_stable) {
56        (false, false) => "baseline and probe sides",
57        (false, true) => "baseline side",
58        (true, false) => "probe side",
59        (true, true) => unreachable!("unstable_result called when both sides are stable"),
60    };
61
62    OracleResult {
63        class: OracleClass::Existence,
64        verdict: OracleVerdict::NotPresent,
65        severity: None,
66        evidence: vec![format!("status codes were inconsistent across samples on {which}")],
67        label: None,
68        leaks: None,
69        rfc_basis: None,
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use bytes::Bytes;
77    use http::{HeaderMap, StatusCode};
78    use parlov_core::{OracleVerdict, ProbeSet, ResponseSurface, Severity};
79
80    fn surface(status: u16) -> ResponseSurface {
81        ResponseSurface {
82            status: StatusCode::from_u16(status).expect("valid status code"),
83            headers: HeaderMap::new(),
84            body: Bytes::new(),
85            timing_ns: 0,
86        }
87    }
88
89    fn probe_set_1(baseline_status: u16, probe_status: u16) -> ProbeSet {
90        ProbeSet {
91            baseline: vec![surface(baseline_status)],
92            probe: vec![surface(probe_status)],
93        }
94    }
95
96    fn probe_set_n(baseline_statuses: &[u16], probe_statuses: &[u16]) -> ProbeSet {
97        ProbeSet {
98            baseline: baseline_statuses.iter().map(|&s| surface(s)).collect(),
99            probe: probe_statuses.iter().map(|&s| surface(s)).collect(),
100        }
101    }
102
103    // --- Round 1 new failing tests (written before implementation) ---
104
105    #[test]
106    fn evaluate_1_sample_diff_returns_need_more() {
107        let ps = probe_set_1(403, 404);
108        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
109    }
110
111    #[test]
112    fn evaluate_2_samples_diff_returns_need_more() {
113        let ps = probe_set_n(&[403, 403], &[404, 404]);
114        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
115    }
116
117    #[test]
118    fn evaluate_3_samples_stable_diff_confirmed() {
119        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
120        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
121            panic!("expected Complete");
122        };
123        assert_eq!(result.verdict, OracleVerdict::Confirmed);
124        assert_eq!(result.severity, Some(Severity::High));
125    }
126
127    #[test]
128    fn evaluate_3_samples_unstable_probe_not_present() {
129        // Probe alternates: 404, 200, 404 — unstable probe side
130        let ps = probe_set_n(&[403, 403, 403], &[404, 200, 404]);
131        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
132            panic!("expected Complete");
133        };
134        assert_eq!(result.verdict, OracleVerdict::NotPresent);
135        assert_eq!(result.severity, None);
136    }
137
138    #[test]
139    fn evaluate_3_samples_unstable_baseline_not_present() {
140        // Baseline alternates: 403, 200, 403 — unstable baseline side
141        let ps = probe_set_n(&[403, 200, 403], &[404, 404, 404]);
142        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
143            panic!("expected Complete");
144        };
145        assert_eq!(result.verdict, OracleVerdict::NotPresent);
146        assert_eq!(result.severity, None);
147    }
148
149    // --- Pre-existing tests (updated where needed) ---
150
151    #[test]
152    fn evaluate_complete_not_present_on_same_status() {
153        // Same status short-circuits at 1 sample → Complete(NotPresent)
154        let ps = probe_set_1(404, 404);
155        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
156            panic!("expected Complete");
157        };
158        assert_eq!(result.verdict, OracleVerdict::NotPresent);
159        assert_eq!(result.severity, None);
160    }
161
162    #[test]
163    fn evaluate_complete_confirmed_high_on_403_vs_404() {
164        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
165        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
166            panic!("expected Complete");
167        };
168        assert_eq!(result.verdict, OracleVerdict::Confirmed);
169        assert_eq!(result.severity, Some(Severity::High));
170    }
171
172    #[test]
173    fn analyze_provided_method_delegates_to_evaluate() {
174        // 3-sample stable ProbeSet so evaluate returns Complete (not NeedMore)
175        let ps = probe_set_n(&[200, 200, 200], &[404, 404, 404]);
176        let result = ExistenceAnalyzer.analyze(&ps);
177        assert_eq!(result.verdict, OracleVerdict::Confirmed);
178        assert_eq!(result.severity, Some(Severity::High));
179    }
180
181    /// Verify `NeedMore` is a valid variant — used by adaptive analyzers in later phases.
182    #[test]
183    fn need_more_variant_is_constructible() {
184        let _decision: SampleDecision = SampleDecision::NeedMore;
185    }
186}