Skip to main content

parlov_analysis/existence/
analyzer.rs

1//! `ExistenceAnalyzer` — delegates classification to the scoring pipeline in `classifier`.
2
3use parlov_core::{
4    DifferentialSet, OracleClass, OracleResult, OracleVerdict, ProbeExchange, StrategyOutcome,
5    Vector,
6};
7
8use crate::aggregation::modifiers::compute_modifiers;
9use crate::signals;
10use crate::{Analyzer, SampleDecision};
11
12use super::classifier::classify;
13
14/// Tracks which code path produced a result, so `classify_outcome` can map it correctly.
15#[derive(Clone, Copy)]
16enum AnalysisPath {
17    /// `is_relevant_differential` returned false — technique did not fire.
18    NotFired,
19    /// Responses were inconsistent across samples.
20    Unstable,
21    /// Baseline status equals probe status — same-status short-circuit.
22    SameStatus,
23    /// `build_result` ran — full signal extraction and scoring completed.
24    FullyScored,
25}
26
27/// Analyzes a `DifferentialSet` for existence oracle signals via status-code differential.
28///
29/// Requires three samples when a differential is detected to confirm stability before
30/// classifying. Short-circuits on same-status pairs (`NotPresent`) after the first sample.
31/// Runs ALL signal extractors unconditionally and delegates scoring to `classifier::classify`.
32pub struct ExistenceAnalyzer;
33
34impl Analyzer for ExistenceAnalyzer {
35    fn evaluate(&self, data: &DifferentialSet) -> SampleDecision {
36        let Some(b0_exchange) = data.baseline.first() else {
37            return SampleDecision::NeedMore;
38        };
39        let Some(p0_exchange) = data.probe.first() else {
40            return SampleDecision::NeedMore;
41        };
42        let b0 = b0_exchange.response.status;
43        let p0 = p0_exchange.response.status;
44
45        if b0 == p0 {
46            let result = build_result(data);
47            let outcome =
48                classify_outcome(&result, AnalysisPath::SameStatus, &data.technique, data);
49            return SampleDecision::Complete(Box::new(result), outcome);
50        }
51
52        if data.baseline.len() < 3 {
53            return SampleDecision::NeedMore;
54        }
55
56        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
57        if stable {
58            if is_relevant_differential(data) {
59                let result = build_result(data);
60                let outcome =
61                    classify_outcome(&result, AnalysisPath::FullyScored, &data.technique, data);
62                SampleDecision::Complete(Box::new(result), outcome)
63            } else {
64                let result = not_fired_result(data);
65                let outcome =
66                    classify_outcome(&result, AnalysisPath::NotFired, &data.technique, data);
67                SampleDecision::Complete(Box::new(result), outcome)
68            }
69        } else {
70            let result = unstable_result(data);
71            let outcome = classify_outcome(&result, AnalysisPath::Unstable, &data.technique, data);
72            SampleDecision::Complete(Box::new(result), outcome)
73        }
74    }
75
76    fn oracle_class(&self) -> OracleClass {
77        OracleClass::Existence
78    }
79}
80
81/// Maps an `AnalysisPath` and the resulting `OracleResult` to a `StrategyOutcome`.
82fn classify_outcome(
83    result: &OracleResult,
84    path: AnalysisPath,
85    technique: &parlov_core::Technique,
86    differential: &DifferentialSet,
87) -> StrategyOutcome {
88    match path {
89        AnalysisPath::NotFired | AnalysisPath::Unstable => {
90            StrategyOutcome::NoSignal(result.clone())
91        }
92        AnalysisPath::SameStatus => same_status_outcome(result, technique, differential),
93        AnalysisPath::FullyScored => match result.verdict {
94            OracleVerdict::Confirmed | OracleVerdict::Likely => {
95                StrategyOutcome::Positive(result.clone())
96            }
97            OracleVerdict::NotPresent | OracleVerdict::Inconclusive => {
98                StrategyOutcome::NoSignal(result.clone())
99            }
100        },
101    }
102}
103
104/// Resolves the `SameStatus` arm into a `StrategyOutcome`.
105///
106/// Applies the runtime [`EvidenceModifiers`](crate::aggregation::modifiers::EvidenceModifiers)
107/// to the technique's `normalization_weight` before emitting `Contradictory`. With all
108/// modifiers at 1.0 the effective weight equals the base weight exactly. Returns
109/// `Inapplicable(reason)` when a precondition gate has fired (auth-gate, method-gate,
110/// parser-failure, or applicability-marker-missing) and `NoSignal` when the technique
111/// declares no `normalization_weight`.
112fn same_status_outcome(
113    result: &OracleResult,
114    technique: &parlov_core::Technique,
115    differential: &DifferentialSet,
116) -> StrategyOutcome {
117    let Some(base_weight) = technique.normalization_weight else {
118        return StrategyOutcome::NoSignal(result.clone());
119    };
120    debug_assert!(
121        base_weight > 0.0,
122        "normalization_weight must be positive; got {base_weight} for {}",
123        technique.id
124    );
125    let mr = compute_modifiers(technique, differential);
126    if mr.is_blocked() {
127        let reason = mr.block_reason.map_or("modifier blocked", |r| r.as_str());
128        return StrategyOutcome::Inapplicable(std::borrow::Cow::Borrowed(reason));
129    }
130    #[allow(clippy::cast_possible_truncation)]
131    let effective_weight = base_weight * mr.modifiers.total() as f32;
132    debug_assert!(
133        effective_weight > 0.0,
134        "effective weight must be positive after modifiers"
135    );
136    StrategyOutcome::Contradictory(result.clone(), effective_weight)
137}
138
139fn build_result(data: &DifferentialSet) -> OracleResult {
140    let b0 = data.baseline[0].response.status;
141    let p0 = data.probe[0].response.status;
142
143    let signals = extract_all_signals(data);
144    classify(b0, p0, signals, &data.technique)
145}
146
147fn extract_all_signals(data: &DifferentialSet) -> Vec<parlov_core::Signal> {
148    let mut out = Vec::new();
149    signals::status_code::extract_into(data, &mut out);
150    signals::header::extract_into(data, &mut out);
151    signals::metadata::extract_into(data, &mut out);
152    signals::body::extract_into(data, &mut out);
153    out
154}
155
156fn is_consistent(exchanges: &[ProbeExchange]) -> bool {
157    exchanges
158        .iter()
159        .all(|e| e.response.status == exchanges[0].response.status)
160}
161
162fn unstable_result(data: &DifferentialSet) -> OracleResult {
163    let baseline_stable = is_consistent(&data.baseline);
164    let probe_stable = is_consistent(&data.probe);
165
166    let which = match (baseline_stable, probe_stable) {
167        (false, false) => "baseline and probe sides",
168        (false, true) => "baseline side",
169        (true, false) => "probe side",
170        // INVARIANT: callers check `!is_consistent` before calling this function,
171        // so at least one side is unstable — (true, true) cannot occur.
172        (true, true) => unreachable!("unstable_result called when both sides are stable"),
173    };
174
175    OracleResult {
176        class: OracleClass::Existence,
177        verdict: OracleVerdict::NotPresent,
178        severity: None,
179        confidence: 0,
180        impact_class: None,
181        reasons: vec![],
182        signals: vec![parlov_core::Signal {
183            kind: parlov_core::SignalKind::StatusCodeDiff,
184            evidence: format!("unstable: {which}"),
185            rfc_basis: None,
186        }],
187        technique_id: Some(data.technique.id.to_string()),
188        vector: Some(data.technique.vector),
189        normative_strength: Some(data.technique.strength),
190        label: None,
191        leaks: None,
192        rfc_basis: None,
193    }
194}
195
196/// Returns `true` when the differential is relevant to the technique that generated it.
197///
198/// Some vectors — particularly `RedirectDiff` — manipulate URLs in ways that can trigger
199/// unexpected server-side behavior unrelated to redirect logic (e.g., `200 vs 412`). When
200/// neither side of the differential is a 3xx, the technique did not fire and the result
201/// must be dismissed rather than scored against the general pattern table.
202fn is_relevant_differential(data: &DifferentialSet) -> bool {
203    let b0 = data.baseline[0].response.status;
204    let p0 = data.probe[0].response.status;
205
206    match data.technique.vector {
207        Vector::RedirectDiff => b0.is_redirection() || p0.is_redirection(),
208        // Other vectors use the b0 == p0 short-circuit for "technique didn't fire."
209        Vector::StatusCodeDiff | Vector::CacheProbing | Vector::ErrorMessageGranularity => true,
210    }
211}
212
213/// Produces a `NotPresent` result annotating that the technique did not fire.
214///
215/// Used when `is_relevant_differential` returns `false` — the differential exists but is
216/// a side effect of probe construction, not the expected signal. The signal evidence records
217/// the dismissal reason so reporting surfaces it rather than silently dropping the result.
218fn not_fired_result(data: &DifferentialSet) -> OracleResult {
219    OracleResult {
220        class: OracleClass::Existence,
221        verdict: OracleVerdict::NotPresent,
222        severity: None,
223        confidence: 0,
224        impact_class: None,
225        reasons: vec![],
226        signals: vec![parlov_core::Signal {
227            kind: parlov_core::SignalKind::StatusCodeDiff,
228            evidence: "technique did not fire: no 3xx status in differential".to_owned(),
229            rfc_basis: None,
230        }],
231        technique_id: Some(data.technique.id.to_string()),
232        vector: Some(data.technique.vector),
233        normative_strength: Some(data.technique.strength),
234        label: None,
235        leaks: None,
236        rfc_basis: None,
237    }
238}
239
240#[cfg(test)]
241#[path = "analyzer_tests.rs"]
242mod tests;