parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
//! Per-strategy outcome type for endpoint-level aggregation.

use serde::{Deserialize, Serialize};

use crate::OracleResult;

/// The outcome of running a single strategy, classified for aggregation.
///
/// Each variant encodes the raw `OracleResult` (where applicable) and the weight instruction
/// to the aggregator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StrategyOutcome {
    /// Strategy observed a differential supporting existence.
    ///
    /// The inner `OracleResult` carries the confidence score, signals, and severity. Contributes
    /// positively to the aggregated verdict.
    Positive(OracleResult),
    /// Strategy ran but produced no actionable differential.
    ///
    /// Includes: technique didn't fire, server normalised responses uniformly, or responses were
    /// unstable across samples. Contributes zero evidence to aggregation.
    NoSignal(OracleResult),
    /// Strategy observed a response pattern that actively supports nonexistence.
    ///
    /// The `f32` is the contradiction weight in `[0.0, 1.0]`. A weight of `1.0` means the server
    /// is definitively normalised; `0.0` is equivalent to `NoSignal`. Contributes negatively to
    /// the aggregated verdict proportional to the weight.
    Contradictory(OracleResult, f32),
    /// Strategy could not run — missing prerequisites or inapplicable context.
    ///
    /// The inner string is a human-readable reason, e.g. `"target does not support ETags"`.
    /// Contributes zero evidence to aggregation.
    Inapplicable(std::borrow::Cow<'static, str>),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        NormativeStrength, OracleClass, OracleVerdict, Severity, Signal, SignalKind, Vector,
    };

    fn positive_result() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::Confirmed,
            severity: Some(Severity::High),
            confidence: 85,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: SignalKind::StatusCodeDiff,
                evidence: "403 (baseline) vs 404 (probe)".into(),
                rfc_basis: Some("RFC 9110 §15.5.4".into()),
            }],
            technique_id: Some("get-200-404".into()),
            vector: Some(Vector::StatusCodeDiff),
            normative_strength: Some(NormativeStrength::Must),
            label: Some("Authorization-based differential".into()),
            leaks: Some("Resource existence confirmed to low-privilege callers".into()),
            rfc_basis: Some("RFC 9110 §15.5.4".into()),
        }
    }

    fn no_signal_result() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::NotPresent,
            severity: None,
            confidence: 0,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: SignalKind::StatusCodeDiff,
                evidence: "404 (baseline) vs 404 (probe)".into(),
                rfc_basis: None,
            }],
            technique_id: Some("get-200-404".into()),
            vector: Some(Vector::StatusCodeDiff),
            normative_strength: Some(NormativeStrength::Must),
            label: None,
            leaks: None,
            rfc_basis: None,
        }
    }

    fn contradictory_result() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::NotPresent,
            severity: None,
            confidence: 0,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: SignalKind::StatusCodeDiff,
                evidence: "200 (baseline) vs 200 (probe) — server normalises".into(),
                rfc_basis: None,
            }],
            technique_id: Some("if-none-match".into()),
            vector: Some(Vector::CacheProbing),
            normative_strength: Some(NormativeStrength::Should),
            label: None,
            leaks: None,
            rfc_basis: None,
        }
    }

    #[test]
    fn roundtrip_positive_inner_oracle_result_survives() {
        let outcome = StrategyOutcome::Positive(positive_result());
        let json = serde_json::to_string(&outcome).expect("serialization failed");
        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
        let StrategyOutcome::Positive(result) = back else {
            panic!("expected Positive variant after roundtrip");
        };
        assert_eq!(result.verdict, OracleVerdict::Confirmed);
        assert_eq!(result.confidence, 85);
        assert_eq!(result.technique_id.as_deref(), Some("get-200-404"));
        assert_eq!(result.signals.len(), 1);
        assert_eq!(result.signals[0].kind, SignalKind::StatusCodeDiff);
        assert_eq!(result.signals[0].evidence, "403 (baseline) vs 404 (probe)");
        assert_eq!(
            result.label.as_deref(),
            Some("Authorization-based differential")
        );
    }

    #[test]
    fn roundtrip_no_signal_inner_oracle_result_survives() {
        let outcome = StrategyOutcome::NoSignal(no_signal_result());
        let json = serde_json::to_string(&outcome).expect("serialization failed");
        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
        let StrategyOutcome::NoSignal(result) = back else {
            panic!("expected NoSignal variant after roundtrip");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert_eq!(result.confidence, 0);
        assert!(result.label.is_none());
        assert!(result.leaks.is_none());
    }

    #[test]
    fn roundtrip_contradictory_weight_survives() {
        let weight: f32 = 0.75;
        let outcome = StrategyOutcome::Contradictory(contradictory_result(), weight);
        let json = serde_json::to_string(&outcome).expect("serialization failed");
        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
        let StrategyOutcome::Contradictory(result, back_weight) = back else {
            panic!("expected Contradictory variant after roundtrip");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert!((back_weight - weight).abs() < f32::EPSILON);
        assert_eq!(result.technique_id.as_deref(), Some("if-none-match"));
    }

    #[test]
    fn roundtrip_inapplicable_reason_string_survives() {
        let reason = "target does not support ETags".to_string();
        let outcome = StrategyOutcome::Inapplicable(std::borrow::Cow::Owned(reason.clone()));
        let json = serde_json::to_string(&outcome).expect("serialization failed");
        let back: StrategyOutcome = serde_json::from_str(&json).expect("deserialization failed");
        let StrategyOutcome::Inapplicable(back_reason) = back else {
            panic!("expected Inapplicable variant after roundtrip");
        };
        assert_eq!(back_reason, reason);
    }

    #[test]
    fn clone_positive() {
        let outcome = StrategyOutcome::Positive(positive_result());
        let cloned = outcome.clone();
        let StrategyOutcome::Positive(result) = cloned else {
            panic!("expected Positive after clone");
        };
        assert_eq!(result.confidence, 85);
    }

    #[test]
    fn clone_no_signal() {
        let outcome = StrategyOutcome::NoSignal(no_signal_result());
        let cloned = outcome.clone();
        let StrategyOutcome::NoSignal(result) = cloned else {
            panic!("expected NoSignal after clone");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
    }

    #[test]
    fn clone_contradictory() {
        let outcome = StrategyOutcome::Contradictory(contradictory_result(), 0.5);
        let cloned = outcome.clone();
        let StrategyOutcome::Contradictory(result, w) = cloned else {
            panic!("expected Contradictory after clone");
        };
        assert_eq!(result.verdict, OracleVerdict::NotPresent);
        assert!((w - 0.5_f32).abs() < f32::EPSILON);
    }

    #[test]
    fn clone_inapplicable() {
        let outcome = StrategyOutcome::Inapplicable(std::borrow::Cow::Borrowed("no ETag support"));
        let cloned = outcome.clone();
        let StrategyOutcome::Inapplicable(reason) = cloned else {
            panic!("expected Inapplicable after clone");
        };
        assert_eq!(reason, "no ETag support");
    }
}