parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! `Strategy` trait: the interface every elicitation strategy must implement.

use http::Method;

use crate::context::ScanContext;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use parlov_core::Technique;

/// An elicitation strategy: maps a `ScanContext` to a set of `ProbeSpec` values.
///
/// Implementations are pure — no I/O, no async. The strategy inspects the
/// context, decides whether it applies, and generates the probe plan. The
/// scheduler owns execution.
pub trait Strategy: Send + Sync {
    /// Identity and risk rating for this strategy.
    ///
    /// Implementations return a reference to a module-level `static`, paying
    /// zero runtime cost.
    fn metadata(&self) -> &'static StrategyMetadata;

    /// Technique definition: oracle class, vector, RFC strength, and contradiction weight.
    ///
    /// Implementations return a reference to a module-level `static`, paying
    /// zero runtime cost. Named `technique_def` to avoid collision with
    /// `ProbeSpec::technique()`.
    fn technique_def(&self) -> &'static Technique;

    /// Stable machine-readable identifier for this strategy, e.g.
    /// `"existence-get-200-404"`.
    fn id(&self) -> &'static str {
        self.metadata().strategy_id
    }

    /// Human-readable display name, e.g. `"GET 200/404 existence"`.
    fn name(&self) -> &'static str {
        self.metadata().strategy_name
    }

    /// Risk classification. The scheduler skips this strategy when
    /// `ctx.max_risk < self.risk()`.
    fn risk(&self) -> RiskLevel {
        self.metadata().risk
    }

    /// HTTP methods this strategy exercises, used for capability filtering.
    fn methods(&self) -> &[Method];

    /// Returns `true` when this strategy can produce useful probes for `ctx`.
    ///
    /// Called before `generate`; the scheduler never calls `generate` when
    /// `is_applicable` returns `false`.
    fn is_applicable(&self, ctx: &ScanContext) -> bool;

    /// Produce probe specifications for `ctx`.
    ///
    /// Only called when `is_applicable` returns `true`. May return an empty
    /// `Vec` if context constraints prevent generation at runtime.
    fn generate(&self, ctx: &ScanContext) -> Vec<ProbeSpec>;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::ScanContext;
    use crate::types::{ProbePair, ProbeSpec, RiskLevel, StrategyMetadata};
    use http::{HeaderMap, Method};
    use parlov_core::{
        always_applicable, NormativeStrength, OracleClass, ProbeDefinition, SignalSurface,
        Technique, Vector,
    };

    static TEST_METADATA: StrategyMetadata = StrategyMetadata {
        strategy_id: "test",
        strategy_name: "Test Strategy",
        risk: RiskLevel::Safe,
    };

    static TEST_TECHNIQUE: Technique = Technique {
        id: "test",
        name: "Test technique",
        oracle_class: OracleClass::Existence,
        vector: Vector::StatusCodeDiff,
        strength: NormativeStrength::Should,
        normalization_weight: Some(0.2),
        inverted_signal_weight: None,
        method_relevant: false,
        parser_relevant: false,
        applicability: always_applicable,
        contradiction_surface: SignalSurface::Status,
    };

    struct TestStrategy;

    impl Strategy for TestStrategy {
        fn metadata(&self) -> &'static StrategyMetadata {
            &TEST_METADATA
        }

        fn technique_def(&self) -> &'static Technique {
            &TEST_TECHNIQUE
        }

        fn methods(&self) -> &[Method] {
            &[Method::GET]
        }

        fn is_applicable(&self, _ctx: &ScanContext) -> bool {
            true
        }

        fn generate(&self, ctx: &ScanContext) -> Vec<ProbeSpec> {
            let def = ProbeDefinition {
                url: ctx.target.clone(),
                method: Method::GET,
                headers: HeaderMap::new(),
                body: None,
            };
            vec![ProbeSpec::Pair(ProbePair {
                baseline: def.clone(),
                probe: def,
                canonical_baseline: None,
                metadata: self.metadata().clone(),
                technique: *self.technique_def(),
                chain_provenance: None,
            })]
        }
    }

    // --- Object safety ---

    #[test]
    fn strategy_is_object_safe() {
        let _: Box<dyn Strategy> = Box::new(TestStrategy);
    }

    #[test]
    fn strategy_id_accessible_via_dyn() {
        let s: Box<dyn Strategy> = Box::new(TestStrategy);
        assert_eq!(s.id(), "test");
    }

    #[test]
    fn strategy_name_accessible_via_dyn() {
        let s: Box<dyn Strategy> = Box::new(TestStrategy);
        assert_eq!(s.name(), "Test Strategy");
    }

    #[test]
    fn strategy_risk_accessible_via_dyn() {
        let s: Box<dyn Strategy> = Box::new(TestStrategy);
        assert_eq!(s.risk(), RiskLevel::Safe);
    }

    // --- Minimal implementation round-trip ---

    #[test]
    fn test_strategy_is_applicable_returns_true() {
        let ctx = ScanContext {
            target: "https://example.com/{id}".to_owned(),
            baseline_id: "1".to_owned(),
            probe_id: "999".to_owned(),
            headers: HeaderMap::new(),
            max_risk: RiskLevel::Safe,
            known_duplicate: None,
            state_field: None,
            alt_credential: None,
            body_template: None,
        };
        assert!(TestStrategy.is_applicable(&ctx));
    }

    #[test]
    fn test_strategy_generate_returns_pair() {
        let ctx = ScanContext {
            target: "https://example.com/{id}".to_owned(),
            baseline_id: "1".to_owned(),
            probe_id: "999".to_owned(),
            headers: HeaderMap::new(),
            max_risk: RiskLevel::Safe,
            known_duplicate: None,
            state_field: None,
            alt_credential: None,
            body_template: None,
        };
        let specs = TestStrategy.generate(&ctx);
        assert_eq!(specs.len(), 1);
        assert!(matches!(specs[0], ProbeSpec::Pair(_)));
    }
}