Skip to main content

parlov_analysis/existence/
analyzer.rs

1//! `ExistenceAnalyzer` — delegates classification to the scoring pipeline in `classifier`.
2
3use parlov_core::{DifferentialSet, OracleClass, OracleResult, OracleVerdict, ProbeExchange, Vector};
4
5use crate::signals;
6use crate::{Analyzer, SampleDecision};
7
8use super::classifier::classify;
9
10/// Analyzes a `DifferentialSet` for existence oracle signals via status-code differential.
11///
12/// Requires three samples when a differential is detected to confirm stability before
13/// classifying. Short-circuits on same-status pairs (`NotPresent`) after the first sample.
14/// Runs ALL signal extractors unconditionally and delegates scoring to `classifier::classify`.
15pub struct ExistenceAnalyzer;
16
17impl Analyzer for ExistenceAnalyzer {
18    fn evaluate(&self, data: &DifferentialSet) -> SampleDecision {
19        let b0 = data.baseline[0].response.status;
20        let p0 = data.probe[0].response.status;
21
22        if b0 == p0 {
23            return SampleDecision::Complete(Box::new(build_result(data)));
24        }
25
26        if data.baseline.len() < 3 {
27            return SampleDecision::NeedMore;
28        }
29
30        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
31        if stable {
32            if is_relevant_differential(data) {
33                SampleDecision::Complete(Box::new(build_result(data)))
34            } else {
35                SampleDecision::Complete(Box::new(not_fired_result(data)))
36            }
37        } else {
38            SampleDecision::Complete(Box::new(unstable_result(data)))
39        }
40    }
41
42    fn oracle_class(&self) -> OracleClass {
43        OracleClass::Existence
44    }
45}
46
47/// Builds the full `OracleResult` from a stable `DifferentialSet`.
48fn build_result(data: &DifferentialSet) -> OracleResult {
49    let b0 = data.baseline[0].response.status;
50    let p0 = data.probe[0].response.status;
51
52    let signals = extract_all_signals(data);
53    classify(b0, p0, signals, &data.technique)
54}
55
56/// Runs all signal extractors unconditionally on every `DifferentialSet`.
57fn extract_all_signals(data: &DifferentialSet) -> Vec<parlov_core::Signal> {
58    let mut out = Vec::new();
59    out.extend(signals::status_code::extract(data));
60    out.extend(signals::header::extract(data));
61    out.extend(signals::metadata::extract(data));
62    out.extend(signals::body::extract(data));
63    out
64}
65
66/// Returns `true` when every exchange in `exchanges` shares the same status as the first.
67fn is_consistent(exchanges: &[ProbeExchange]) -> bool {
68    exchanges
69        .iter()
70        .all(|e| e.response.status == exchanges[0].response.status)
71}
72
73fn unstable_result(data: &DifferentialSet) -> OracleResult {
74    let baseline_stable = is_consistent(&data.baseline);
75    let probe_stable = is_consistent(&data.probe);
76
77    let which = match (baseline_stable, probe_stable) {
78        (false, false) => "baseline and probe sides",
79        (false, true) => "baseline side",
80        (true, false) => "probe side",
81        (true, true) => unreachable!("unstable_result called when both sides are stable"),
82    };
83
84    OracleResult {
85        class: OracleClass::Existence,
86        verdict: OracleVerdict::NotPresent,
87        severity: None,
88        confidence: 0,
89        impact_class: None,
90        reasons: vec![],
91        signals: vec![parlov_core::Signal {
92            kind: parlov_core::SignalKind::StatusCodeDiff,
93            evidence: format!("unstable: {which}"),
94            rfc_basis: None,
95        }],
96        technique_id: Some(data.technique.id.to_string()),
97        vector: Some(data.technique.vector),
98        normative_strength: Some(data.technique.strength),
99        label: None,
100        leaks: None,
101        rfc_basis: None,
102    }
103}
104
105/// Returns `true` when the differential is relevant to the technique that generated it.
106///
107/// Some vectors — particularly `RedirectDiff` — manipulate URLs in ways that can trigger
108/// unexpected server-side behavior unrelated to redirect logic (e.g., `200 vs 412`). When
109/// neither side of the differential is a 3xx, the technique did not fire and the result
110/// must be dismissed rather than scored against the general pattern table.
111fn is_relevant_differential(data: &DifferentialSet) -> bool {
112    let b0 = data.baseline[0].response.status;
113    let p0 = data.probe[0].response.status;
114
115    match data.technique.vector {
116        Vector::RedirectDiff => b0.is_redirection() || p0.is_redirection(),
117        // Other vectors use the b0 == p0 short-circuit for "technique didn't fire."
118        Vector::StatusCodeDiff
119        | Vector::CacheProbing
120        | Vector::ErrorMessageGranularity => true,
121    }
122}
123
124/// Produces a `NotPresent` result annotating that the technique did not fire.
125///
126/// Used when `is_relevant_differential` returns `false` — the differential exists but is
127/// a side effect of probe construction, not the expected signal. The signal evidence records
128/// the dismissal reason so reporting surfaces it rather than silently dropping the result.
129fn not_fired_result(data: &DifferentialSet) -> OracleResult {
130    OracleResult {
131        class: OracleClass::Existence,
132        verdict: OracleVerdict::NotPresent,
133        severity: None,
134        confidence: 0,
135        impact_class: None,
136        reasons: vec![],
137        signals: vec![parlov_core::Signal {
138            kind: parlov_core::SignalKind::StatusCodeDiff,
139            evidence: "technique did not fire: no 3xx status in differential".to_owned(),
140            rfc_basis: None,
141        }],
142        technique_id: Some(data.technique.id.to_string()),
143        vector: Some(data.technique.vector),
144        normative_strength: Some(data.technique.strength),
145        label: None,
146        leaks: None,
147        rfc_basis: None,
148    }
149}
150
151#[cfg(test)]
152#[path = "analyzer_tests.rs"]
153mod tests;