parlov-analysis 0.3.0

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

#![deny(clippy::all)]
#![warn(clippy::pedantic)]

use parlov_core::{OracleClass, OracleResult, OracleVerdict, ProbeSet, ResponseSummary, ResponseSurface};

use crate::{Analyzer, SampleDecision};

use super::classifier::classify;
use super::diff::diff_headers;

/// Analyzes a `ProbeSet` for GET 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.
/// Delegates pattern classification to `classifier::classify`.
pub struct ExistenceAnalyzer;

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

        if b0 == p0 {
            return SampleDecision::Complete(annotate(classify(b0, p0), &data.baseline[0], &data.probe[0]));
        }

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

        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
        if stable {
            SampleDecision::Complete(annotate(classify(b0, p0), &data.baseline[0], &data.probe[0]))
        } else {
            SampleDecision::Complete(unstable_result(&data.baseline, &data.probe))
        }
    }

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

/// Returns `true` when every surface in `surfaces` shares the same status as the first.
fn is_consistent(surfaces: &[ResponseSurface]) -> bool {
    surfaces.iter().all(|s| s.status == surfaces[0].status)
}

fn unstable_result(baseline: &[ResponseSurface], probe: &[ResponseSurface]) -> OracleResult {
    let baseline_stable = is_consistent(baseline);
    let probe_stable = is_consistent(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"),
    };

    let result = OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        evidence: vec![format!("status codes were inconsistent across samples on {which}")],
        label: None,
        leaks: None,
        rfc_basis: None,
        baseline_summary: None,
        probe_summary: None,
        header_diffs: vec![],
    };
    annotate(result, &baseline[0], &probe[0])
}

/// Patches `baseline_summary`, `probe_summary`, and `header_diffs` onto a result.
///
/// Called after `classify` or `unstable_result` so that every returned `OracleResult`
/// carries structured surface data regardless of which code path produced the verdict.
fn annotate(
    mut result: OracleResult,
    baseline: &ResponseSurface,
    probe: &ResponseSurface,
) -> OracleResult {
    result.baseline_summary = Some(ResponseSummary { status: baseline.status.as_u16() });
    result.probe_summary = Some(ResponseSummary { status: probe.status.as_u16() });
    result.header_diffs = diff_headers(&baseline.headers, &probe.headers);
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use bytes::Bytes;
    use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
    use parlov_core::{OracleVerdict, ProbeSet, ResponseSurface, ResponseSummary, Severity};

    fn surface(status: u16) -> ResponseSurface {
        ResponseSurface {
            status: StatusCode::from_u16(status).expect("valid status code"),
            headers: HeaderMap::new(),
            body: Bytes::new(),
            timing_ns: 0,
        }
    }

    fn surface_with_header(status: u16, name: &str, value: &str) -> ResponseSurface {
        let mut headers = HeaderMap::new();
        let header_name = HeaderName::from_bytes(name.as_bytes()).expect("valid header name");
        let header_value = HeaderValue::from_str(value).expect("valid header value");
        headers.insert(header_name, header_value);
        ResponseSurface {
            status: StatusCode::from_u16(status).expect("valid status code"),
            headers,
            body: Bytes::new(),
            timing_ns: 0,
        }
    }

    fn probe_set_1(baseline_status: u16, probe_status: u16) -> ProbeSet {
        ProbeSet {
            baseline: vec![surface(baseline_status)],
            probe: vec![surface(probe_status)],
        }
    }

    fn probe_set_n(baseline_statuses: &[u16], probe_statuses: &[u16]) -> ProbeSet {
        ProbeSet {
            baseline: baseline_statuses.iter().map(|&s| surface(s)).collect(),
            probe: probe_statuses.iter().map(|&s| surface(s)).collect(),
        }
    }

    // --- Round 1 new failing tests (written before implementation) ---

    #[test]
    fn evaluate_1_sample_diff_returns_need_more() {
        let ps = probe_set_1(403, 404);
        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
    }

    #[test]
    fn evaluate_2_samples_diff_returns_need_more() {
        let ps = probe_set_n(&[403, 403], &[404, 404]);
        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
    }

    #[test]
    fn evaluate_3_samples_stable_diff_confirmed() {
        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.verdict, OracleVerdict::Confirmed);
        assert_eq!(result.severity, Some(Severity::High));
    }

    #[test]
    fn evaluate_3_samples_unstable_probe_not_present() {
        // Probe alternates: 404, 200, 404 — unstable probe side
        let ps = probe_set_n(&[403, 403, 403], &[404, 200, 404]);
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert_eq!(result.severity, None);
    }

    #[test]
    fn evaluate_3_samples_unstable_baseline_not_present() {
        // Baseline alternates: 403, 200, 403 — unstable baseline side
        let ps = probe_set_n(&[403, 200, 403], &[404, 404, 404]);
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert_eq!(result.severity, None);
    }

    // --- Pre-existing tests (updated where needed) ---

    #[test]
    fn evaluate_complete_not_present_on_same_status() {
        // Same status short-circuits at 1 sample → Complete(NotPresent)
        let ps = probe_set_1(404, 404);
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert_eq!(result.severity, None);
    }

    #[test]
    fn evaluate_complete_confirmed_high_on_403_vs_404() {
        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.verdict, OracleVerdict::Confirmed);
        assert_eq!(result.severity, Some(Severity::High));
    }

    #[test]
    fn analyze_provided_method_delegates_to_evaluate() {
        // 3-sample stable ProbeSet so evaluate returns Complete (not NeedMore)
        let ps = probe_set_n(&[200, 200, 200], &[404, 404, 404]);
        let result = ExistenceAnalyzer.analyze(&ps);
        assert_eq!(result.verdict, OracleVerdict::Confirmed);
        assert_eq!(result.severity, Some(Severity::High));
    }

    /// Verify `NeedMore` is a valid variant — used by adaptive analyzers in later phases.
    #[test]
    fn need_more_variant_is_constructible() {
        let _decision: SampleDecision = SampleDecision::NeedMore;
    }

    #[test]
    fn evaluate_populates_baseline_and_probe_summary() {
        let ps = ProbeSet {
            baseline: vec![surface(403), surface(403), surface(403)],
            probe: vec![surface(404), surface(404), surface(404)],
        };
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.baseline_summary, Some(ResponseSummary { status: 403 }));
        assert_eq!(result.probe_summary, Some(ResponseSummary { status: 404 }));
    }

    #[test]
    fn evaluate_populates_header_diffs_when_headers_differ() {
        let ps = ProbeSet {
            baseline: vec![
                surface_with_header(403, "x-error-code", "FORBIDDEN"),
                surface_with_header(403, "x-error-code", "FORBIDDEN"),
                surface_with_header(403, "x-error-code", "FORBIDDEN"),
            ],
            probe: vec![
                surface_with_header(404, "x-error-code", "NOT_FOUND"),
                surface_with_header(404, "x-error-code", "NOT_FOUND"),
                surface_with_header(404, "x-error-code", "NOT_FOUND"),
            ],
        };
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.header_diffs.len(), 1);
        assert_eq!(result.header_diffs[0].name, "x-error-code");
        assert_eq!(result.header_diffs[0].baseline, Some("FORBIDDEN".to_string()));
        assert_eq!(result.header_diffs[0].probe, Some("NOT_FOUND".to_string()));
    }

    #[test]
    fn evaluate_empty_header_diffs_when_headers_identical() {
        let ps = ProbeSet {
            baseline: vec![
                surface_with_header(403, "x-request-id", "abc"),
                surface_with_header(403, "x-request-id", "abc"),
                surface_with_header(403, "x-request-id", "abc"),
            ],
            probe: vec![
                surface_with_header(404, "x-request-id", "abc"),
                surface_with_header(404, "x-request-id", "abc"),
                surface_with_header(404, "x-request-id", "abc"),
            ],
        };
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert!(result.header_diffs.is_empty());
    }

    #[test]
    fn evaluate_header_absent_on_probe_side() {
        let ps = ProbeSet {
            baseline: vec![
                surface_with_header(403, "x-resource-id", "123"),
                surface_with_header(403, "x-resource-id", "123"),
                surface_with_header(403, "x-resource-id", "123"),
            ],
            probe: vec![surface(404), surface(404), surface(404)],
        };
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        let diff = result
            .header_diffs
            .iter()
            .find(|d| d.name == "x-resource-id")
            .expect("expected x-resource-id diff");
        assert_eq!(diff.baseline, Some("123".to_string()));
        assert_eq!(diff.probe, None);
    }

    #[test]
    fn unstable_result_populates_summaries() {
        // Probe alternates: unstable probe side triggers unstable_result path
        let ps = ProbeSet {
            baseline: vec![surface(403), surface(403), surface(403)],
            probe: vec![surface(404), surface(200), surface(404)],
        };
        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
            panic!("expected Complete");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert_eq!(result.baseline_summary, Some(ResponseSummary { status: 403 }));
        assert_eq!(result.probe_summary, Some(ResponseSummary { status: 404 }));
    }
}