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, ResponseSummary, ResponseSurface};
7
8use crate::{Analyzer, SampleDecision};
9
10use super::classifier::classify;
11use super::diff::diff_headers;
12
13/// Analyzes a `ProbeSet` for GET existence oracle signals via status-code differential.
14///
15/// Requires three samples when a differential is detected to confirm stability before
16/// classifying. Short-circuits on same-status pairs (`NotPresent`) after the first sample.
17/// Delegates pattern classification to `classifier::classify`.
18pub struct ExistenceAnalyzer;
19
20impl Analyzer for ExistenceAnalyzer {
21    fn evaluate(&self, data: &ProbeSet) -> SampleDecision {
22        let b0 = data.baseline[0].status;
23        let p0 = data.probe[0].status;
24
25        if b0 == p0 {
26            return SampleDecision::Complete(annotate(classify(b0, p0), &data.baseline[0], &data.probe[0]));
27        }
28
29        let samples = data.baseline.len();
30        if samples < 3 {
31            return SampleDecision::NeedMore;
32        }
33
34        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
35        if stable {
36            SampleDecision::Complete(annotate(classify(b0, p0), &data.baseline[0], &data.probe[0]))
37        } else {
38            SampleDecision::Complete(unstable_result(&data.baseline, &data.probe))
39        }
40    }
41
42    fn oracle_class(&self) -> OracleClass {
43        OracleClass::Existence
44    }
45}
46
47/// Returns `true` when every surface in `surfaces` shares the same status as the first.
48fn is_consistent(surfaces: &[ResponseSurface]) -> bool {
49    surfaces.iter().all(|s| s.status == surfaces[0].status)
50}
51
52fn unstable_result(baseline: &[ResponseSurface], probe: &[ResponseSurface]) -> OracleResult {
53    let baseline_stable = is_consistent(baseline);
54    let probe_stable = is_consistent(probe);
55
56    let which = match (baseline_stable, probe_stable) {
57        (false, false) => "baseline and probe sides",
58        (false, true) => "baseline side",
59        (true, false) => "probe side",
60        (true, true) => unreachable!("unstable_result called when both sides are stable"),
61    };
62
63    let result = OracleResult {
64        class: OracleClass::Existence,
65        verdict: OracleVerdict::NotPresent,
66        severity: None,
67        evidence: vec![format!("status codes were inconsistent across samples on {which}")],
68        label: None,
69        leaks: None,
70        rfc_basis: None,
71        baseline_summary: None,
72        probe_summary: None,
73        header_diffs: vec![],
74    };
75    annotate(result, &baseline[0], &probe[0])
76}
77
78/// Patches `baseline_summary`, `probe_summary`, and `header_diffs` onto a result.
79///
80/// Called after `classify` or `unstable_result` so that every returned `OracleResult`
81/// carries structured surface data regardless of which code path produced the verdict.
82fn annotate(
83    mut result: OracleResult,
84    baseline: &ResponseSurface,
85    probe: &ResponseSurface,
86) -> OracleResult {
87    result.baseline_summary = Some(ResponseSummary { status: baseline.status.as_u16() });
88    result.probe_summary = Some(ResponseSummary { status: probe.status.as_u16() });
89    result.header_diffs = diff_headers(&baseline.headers, &probe.headers);
90    result
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use bytes::Bytes;
97    use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
98    use parlov_core::{OracleVerdict, ProbeSet, ResponseSurface, ResponseSummary, Severity};
99
100    fn surface(status: u16) -> ResponseSurface {
101        ResponseSurface {
102            status: StatusCode::from_u16(status).expect("valid status code"),
103            headers: HeaderMap::new(),
104            body: Bytes::new(),
105            timing_ns: 0,
106        }
107    }
108
109    fn surface_with_header(status: u16, name: &str, value: &str) -> ResponseSurface {
110        let mut headers = HeaderMap::new();
111        let header_name = HeaderName::from_bytes(name.as_bytes()).expect("valid header name");
112        let header_value = HeaderValue::from_str(value).expect("valid header value");
113        headers.insert(header_name, header_value);
114        ResponseSurface {
115            status: StatusCode::from_u16(status).expect("valid status code"),
116            headers,
117            body: Bytes::new(),
118            timing_ns: 0,
119        }
120    }
121
122    fn probe_set_1(baseline_status: u16, probe_status: u16) -> ProbeSet {
123        ProbeSet {
124            baseline: vec![surface(baseline_status)],
125            probe: vec![surface(probe_status)],
126        }
127    }
128
129    fn probe_set_n(baseline_statuses: &[u16], probe_statuses: &[u16]) -> ProbeSet {
130        ProbeSet {
131            baseline: baseline_statuses.iter().map(|&s| surface(s)).collect(),
132            probe: probe_statuses.iter().map(|&s| surface(s)).collect(),
133        }
134    }
135
136    // --- Round 1 new failing tests (written before implementation) ---
137
138    #[test]
139    fn evaluate_1_sample_diff_returns_need_more() {
140        let ps = probe_set_1(403, 404);
141        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
142    }
143
144    #[test]
145    fn evaluate_2_samples_diff_returns_need_more() {
146        let ps = probe_set_n(&[403, 403], &[404, 404]);
147        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
148    }
149
150    #[test]
151    fn evaluate_3_samples_stable_diff_confirmed() {
152        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
153        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
154            panic!("expected Complete");
155        };
156        assert_eq!(result.verdict, OracleVerdict::Confirmed);
157        assert_eq!(result.severity, Some(Severity::High));
158    }
159
160    #[test]
161    fn evaluate_3_samples_unstable_probe_not_present() {
162        // Probe alternates: 404, 200, 404 — unstable probe side
163        let ps = probe_set_n(&[403, 403, 403], &[404, 200, 404]);
164        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
165            panic!("expected Complete");
166        };
167        assert_eq!(result.verdict, OracleVerdict::NotPresent);
168        assert_eq!(result.severity, None);
169    }
170
171    #[test]
172    fn evaluate_3_samples_unstable_baseline_not_present() {
173        // Baseline alternates: 403, 200, 403 — unstable baseline side
174        let ps = probe_set_n(&[403, 200, 403], &[404, 404, 404]);
175        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
176            panic!("expected Complete");
177        };
178        assert_eq!(result.verdict, OracleVerdict::NotPresent);
179        assert_eq!(result.severity, None);
180    }
181
182    // --- Pre-existing tests (updated where needed) ---
183
184    #[test]
185    fn evaluate_complete_not_present_on_same_status() {
186        // Same status short-circuits at 1 sample → Complete(NotPresent)
187        let ps = probe_set_1(404, 404);
188        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
189            panic!("expected Complete");
190        };
191        assert_eq!(result.verdict, OracleVerdict::NotPresent);
192        assert_eq!(result.severity, None);
193    }
194
195    #[test]
196    fn evaluate_complete_confirmed_high_on_403_vs_404() {
197        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
198        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
199            panic!("expected Complete");
200        };
201        assert_eq!(result.verdict, OracleVerdict::Confirmed);
202        assert_eq!(result.severity, Some(Severity::High));
203    }
204
205    #[test]
206    fn analyze_provided_method_delegates_to_evaluate() {
207        // 3-sample stable ProbeSet so evaluate returns Complete (not NeedMore)
208        let ps = probe_set_n(&[200, 200, 200], &[404, 404, 404]);
209        let result = ExistenceAnalyzer.analyze(&ps);
210        assert_eq!(result.verdict, OracleVerdict::Confirmed);
211        assert_eq!(result.severity, Some(Severity::High));
212    }
213
214    /// Verify `NeedMore` is a valid variant — used by adaptive analyzers in later phases.
215    #[test]
216    fn need_more_variant_is_constructible() {
217        let _decision: SampleDecision = SampleDecision::NeedMore;
218    }
219
220    #[test]
221    fn evaluate_populates_baseline_and_probe_summary() {
222        let ps = ProbeSet {
223            baseline: vec![surface(403), surface(403), surface(403)],
224            probe: vec![surface(404), surface(404), surface(404)],
225        };
226        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
227            panic!("expected Complete");
228        };
229        assert_eq!(result.baseline_summary, Some(ResponseSummary { status: 403 }));
230        assert_eq!(result.probe_summary, Some(ResponseSummary { status: 404 }));
231    }
232
233    #[test]
234    fn evaluate_populates_header_diffs_when_headers_differ() {
235        let ps = ProbeSet {
236            baseline: vec![
237                surface_with_header(403, "x-error-code", "FORBIDDEN"),
238                surface_with_header(403, "x-error-code", "FORBIDDEN"),
239                surface_with_header(403, "x-error-code", "FORBIDDEN"),
240            ],
241            probe: vec![
242                surface_with_header(404, "x-error-code", "NOT_FOUND"),
243                surface_with_header(404, "x-error-code", "NOT_FOUND"),
244                surface_with_header(404, "x-error-code", "NOT_FOUND"),
245            ],
246        };
247        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
248            panic!("expected Complete");
249        };
250        assert_eq!(result.header_diffs.len(), 1);
251        assert_eq!(result.header_diffs[0].name, "x-error-code");
252        assert_eq!(result.header_diffs[0].baseline, Some("FORBIDDEN".to_string()));
253        assert_eq!(result.header_diffs[0].probe, Some("NOT_FOUND".to_string()));
254    }
255
256    #[test]
257    fn evaluate_empty_header_diffs_when_headers_identical() {
258        let ps = ProbeSet {
259            baseline: vec![
260                surface_with_header(403, "x-request-id", "abc"),
261                surface_with_header(403, "x-request-id", "abc"),
262                surface_with_header(403, "x-request-id", "abc"),
263            ],
264            probe: vec![
265                surface_with_header(404, "x-request-id", "abc"),
266                surface_with_header(404, "x-request-id", "abc"),
267                surface_with_header(404, "x-request-id", "abc"),
268            ],
269        };
270        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
271            panic!("expected Complete");
272        };
273        assert!(result.header_diffs.is_empty());
274    }
275
276    #[test]
277    fn evaluate_header_absent_on_probe_side() {
278        let ps = ProbeSet {
279            baseline: vec![
280                surface_with_header(403, "x-resource-id", "123"),
281                surface_with_header(403, "x-resource-id", "123"),
282                surface_with_header(403, "x-resource-id", "123"),
283            ],
284            probe: vec![surface(404), surface(404), surface(404)],
285        };
286        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
287            panic!("expected Complete");
288        };
289        let diff = result
290            .header_diffs
291            .iter()
292            .find(|d| d.name == "x-resource-id")
293            .expect("expected x-resource-id diff");
294        assert_eq!(diff.baseline, Some("123".to_string()));
295        assert_eq!(diff.probe, None);
296    }
297
298    #[test]
299    fn unstable_result_populates_summaries() {
300        // Probe alternates: unstable probe side triggers unstable_result path
301        let ps = ProbeSet {
302            baseline: vec![surface(403), surface(403), surface(403)],
303            probe: vec![surface(404), surface(200), surface(404)],
304        };
305        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
306            panic!("expected Complete");
307        };
308        assert_eq!(result.verdict, OracleVerdict::NotPresent);
309        assert_eq!(result.baseline_summary, Some(ResponseSummary { status: 403 }));
310        assert_eq!(result.probe_summary, Some(ResponseSummary { status: 404 }));
311    }
312}