parlov-elicit 0.1.1

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! Core elicitation types: risk classification, probe specifications, and strategy metadata.

use parlov_core::ProbeDefinition;
use serde::{Deserialize, Serialize};

/// Risk classification for a probing strategy.
///
/// Controls whether a strategy is eligible for execution given the operator's
/// `max_risk` ceiling in `ScanContext`. Variants are ordered from safest to most
/// destructive so that `<=` comparisons work correctly for ceiling enforcement.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum RiskLevel {
    /// Read-only probing. No state mutation; safe on any target.
    Safe,
    /// Uses non-idempotent HTTP methods (e.g. POST, PATCH, PUT) that may have
    /// side-effects, but the strategy avoids permanent data loss.
    MethodDestructive,
    /// May trigger irreversible server-side state changes (e.g. DELETE, account
    /// closure, resource exhaustion).
    OperationDestructive,
}

/// Metadata describing a strategy: identity and risk rating.
///
/// Carried on every `ProbeSpec` so the scheduler and output layer can attribute
/// each probe pair back to the strategy that generated it without holding a
/// reference to the strategy object.
#[derive(Debug, Clone)]
pub struct StrategyMetadata {
    /// Stable machine-readable identifier, e.g. `"existence-get-200-404"`.
    pub strategy_id: &'static str,
    /// Human-readable display name, e.g. `"GET 200/404 existence"`.
    pub strategy_name: &'static str,
    /// Risk classification for the strategy.
    pub risk: RiskLevel,
}

/// A baseline + probe pair for standard differential analysis.
///
/// The scheduler executes `baseline` and `probe` the required number of times,
/// collecting `ResponseSurface` values into a `ProbeSet` for analysis.
#[derive(Debug, Clone)]
pub struct ProbePair {
    /// The control request targeting a known-existing resource.
    pub baseline: ProbeDefinition,
    /// The probe request targeting the unknown or suspect resource.
    pub probe: ProbeDefinition,
    /// Strategy that generated this pair.
    pub metadata: StrategyMetadata,
}

/// A burst probe: N requests to baseline URL, then N to probe URL.
///
/// Used for timing oracles where multiple samples are required for statistical
/// significance. `burst_count` sets the minimum sample size per side; the
/// adaptive timing analyzer may request additional rounds.
#[derive(Debug, Clone)]
pub struct BurstSpec {
    /// The control request targeting a known-existing resource.
    pub baseline: ProbeDefinition,
    /// The probe request targeting the unknown or suspect resource.
    pub probe: ProbeDefinition,
    /// Minimum number of samples to collect per side before analysis.
    pub burst_count: usize,
    /// Strategy that generated this burst.
    pub metadata: StrategyMetadata,
}

/// The unit of work handed to the probe scheduler.
///
/// Each variant encodes a different execution and collection strategy. The
/// scheduler dispatches on the variant; the analysis layer receives a `ProbeSet`
/// regardless of which variant produced it.
#[derive(Debug, Clone)]
pub enum ProbeSpec {
    /// Standard adaptive loop: one baseline request, one probe request.
    Pair(ProbePair),
    /// Send `burst_count` requests to the baseline URL, then `burst_count` to
    /// the probe URL, for statistical timing analysis.
    Burst(BurstSpec),
    /// One baseline + one probe; compare full response header sets rather than
    /// bodies or status codes.
    HeaderDiff(ProbePair),
}

#[cfg(test)]
mod tests {
    use super::*;
    use http::{HeaderMap, Method};

    fn make_probe_def(url: &str, method: Method) -> ProbeDefinition {
        ProbeDefinition {
            url: url.to_owned(),
            method,
            headers: HeaderMap::new(),
            body: None,
        }
    }

    fn make_metadata() -> StrategyMetadata {
        StrategyMetadata {
            strategy_id: "test-strategy",
            strategy_name: "Test Strategy",
            risk: RiskLevel::Safe,
        }
    }

    // --- RiskLevel ordering ---

    #[test]
    fn risk_level_safe_less_than_method_destructive() {
        assert!(RiskLevel::Safe < RiskLevel::MethodDestructive);
    }

    #[test]
    fn risk_level_method_destructive_less_than_operation_destructive() {
        assert!(RiskLevel::MethodDestructive < RiskLevel::OperationDestructive);
    }

    #[test]
    fn risk_level_safe_less_than_operation_destructive() {
        assert!(RiskLevel::Safe < RiskLevel::OperationDestructive);
    }

    // --- RiskLevel serialization round-trip ---

    #[test]
    fn risk_level_method_destructive_roundtrip() {
        let original = RiskLevel::MethodDestructive;
        let json = serde_json::to_string(&original).expect("serialize failed");
        let deserialized: RiskLevel = serde_json::from_str(&json).expect("deserialize failed");
        assert_eq!(deserialized, original);
    }

    // --- Type construction ---

    #[test]
    fn strategy_metadata_fields_accessible() {
        let m = make_metadata();
        assert_eq!(m.strategy_id, "test-strategy");
        assert_eq!(m.strategy_name, "Test Strategy");
        assert_eq!(m.risk, RiskLevel::Safe);
    }

    #[test]
    fn probe_pair_fields_accessible() {
        let pair = ProbePair {
            baseline: make_probe_def("https://example.com/1", Method::GET),
            probe: make_probe_def("https://example.com/999", Method::GET),
            metadata: make_metadata(),
        };
        assert_eq!(pair.baseline.url, "https://example.com/1");
        assert_eq!(pair.probe.url, "https://example.com/999");
        assert_eq!(pair.metadata.risk, RiskLevel::Safe);
    }

    #[test]
    fn burst_spec_fields_accessible() {
        let burst = BurstSpec {
            baseline: make_probe_def("https://example.com/1", Method::GET),
            probe: make_probe_def("https://example.com/999", Method::GET),
            burst_count: 30,
            metadata: make_metadata(),
        };
        assert_eq!(burst.burst_count, 30);
    }

    #[test]
    fn probe_spec_pair_variant_accessible() {
        let spec = ProbeSpec::Pair(ProbePair {
            baseline: make_probe_def("https://example.com/1", Method::GET),
            probe: make_probe_def("https://example.com/999", Method::GET),
            metadata: make_metadata(),
        });
        assert!(matches!(spec, ProbeSpec::Pair(_)));
    }

    #[test]
    fn probe_spec_burst_variant_accessible() {
        let spec = ProbeSpec::Burst(BurstSpec {
            baseline: make_probe_def("https://example.com/1", Method::GET),
            probe: make_probe_def("https://example.com/999", Method::GET),
            burst_count: 30,
            metadata: make_metadata(),
        });
        assert!(matches!(spec, ProbeSpec::Burst(_)));
    }

    #[test]
    fn probe_spec_header_diff_variant_accessible() {
        let spec = ProbeSpec::HeaderDiff(ProbePair {
            baseline: make_probe_def("https://example.com/1", Method::GET),
            probe: make_probe_def("https://example.com/999", Method::GET),
            metadata: make_metadata(),
        });
        assert!(matches!(spec, ProbeSpec::HeaderDiff(_)));
    }

}