parlov-analysis 0.4.0

Analysis engine trait and signal detection for parlov.
Documentation
//! `ExistenceAnalyzer` — delegates classification to the scoring pipeline in `classifier`.

use parlov_core::{DifferentialSet, OracleClass, OracleResult, OracleVerdict, ProbeExchange};

use crate::signals;
use crate::{Analyzer, SampleDecision};

use super::classifier::classify;

/// Analyzes a `DifferentialSet` for existence oracle signals via status-code differential.
///
/// Requires three samples when a differential is detected to confirm stability before
/// classifying. Short-circuits on same-status pairs (`NotPresent`) after the first sample.
/// Runs ALL signal extractors unconditionally and delegates scoring to `classifier::classify`.
pub struct ExistenceAnalyzer;

impl Analyzer for ExistenceAnalyzer {
    fn evaluate(&self, data: &DifferentialSet) -> SampleDecision {
        let b0 = data.baseline[0].response.status;
        let p0 = data.probe[0].response.status;

        if b0 == p0 {
            return SampleDecision::Complete(Box::new(build_result(data)));
        }

        if data.baseline.len() < 3 {
            return SampleDecision::NeedMore;
        }

        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
        if stable {
            SampleDecision::Complete(Box::new(build_result(data)))
        } else {
            SampleDecision::Complete(Box::new(unstable_result(data)))
        }
    }

    fn oracle_class(&self) -> OracleClass {
        OracleClass::Existence
    }
}

/// Builds the full `OracleResult` from a stable `DifferentialSet`.
fn build_result(data: &DifferentialSet) -> OracleResult {
    let b0 = data.baseline[0].response.status;
    let p0 = data.probe[0].response.status;

    let signals = extract_all_signals(data);
    classify(b0, p0, signals, &data.technique)
}

/// Runs all signal extractors unconditionally on every `DifferentialSet`.
fn extract_all_signals(data: &DifferentialSet) -> Vec<parlov_core::Signal> {
    let mut out = Vec::new();
    out.extend(signals::status_code::extract(data));
    out.extend(signals::header::extract(data));
    out.extend(signals::metadata::extract(data));
    out
}

/// Returns `true` when every exchange in `exchanges` shares the same status as the first.
fn is_consistent(exchanges: &[ProbeExchange]) -> bool {
    exchanges
        .iter()
        .all(|e| e.response.status == exchanges[0].response.status)
}

fn unstable_result(data: &DifferentialSet) -> OracleResult {
    let baseline_stable = is_consistent(&data.baseline);
    let probe_stable = is_consistent(&data.probe);

    let which = match (baseline_stable, probe_stable) {
        (false, false) => "baseline and probe sides",
        (false, true) => "baseline side",
        (true, false) => "probe side",
        (true, true) => unreachable!("unstable_result called when both sides are stable"),
    };

    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![parlov_core::Signal {
            kind: parlov_core::SignalKind::StatusCodeDiff,
            evidence: format!("unstable: {which}"),
            rfc_basis: None,
        }],
        technique_id: Some(data.technique.id.to_string()),
        vector: Some(data.technique.vector),
        normative_strength: Some(data.technique.strength),
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

#[cfg(test)]
#[path = "analyzer_tests.rs"]
mod tests;