parlov-elicit 0.1.1

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! Strategy registry: discovery and probe plan generation.
//!
//! `all_strategies` returns every registered elicitation strategy.
//! `generate_plan` applies risk and applicability filtering, then collects
//! all generated `ProbeSpec` values into an ordered plan for the scheduler.

use crate::context::ScanContext;
use crate::strategies::accept::AcceptElicitation;
use crate::strategies::auth_strip::AuthStripElicitation;
use crate::strategies::case_normalize::CaseNormalizeElicitation;
use crate::strategies::content_type::ContentTypeElicitation;
use crate::strategies::dependency_delete::DependencyDeleteElicitation;
use crate::strategies::empty_body::EmptyBodyElicitation;
use crate::strategies::if_match::IfMatchElicitation;
use crate::strategies::if_none_match::IfNoneMatchElicitation;
use crate::strategies::long_uri::LongUriElicitation;
use crate::strategies::low_privilege::LowPrivilegeElicitation;
use crate::strategies::oversized_body::OversizedBodyElicitation;
use crate::strategies::rate_limit_burst::RateLimitBurstElicitation;
use crate::strategies::rate_limit_headers::RateLimitHeadersElicitation;
use crate::strategies::scope_manipulation::ScopeManipulationElicitation;
use crate::strategies::state_transition::StateTransitionElicitation;
use crate::strategies::trailing_slash::TrailingSlashElicitation;
use crate::strategies::uniqueness::UniquenessElicitation;
use crate::strategy::Strategy;
use crate::types::ProbeSpec;

/// Returns all 17 registered elicitation strategies.
///
/// Grouped by risk level in declaration order: 9 Safe, 5 `MethodDestructive`,
/// 3 `OperationDestructive`. The scheduler may reorder by risk or method.
#[must_use]
pub fn all_strategies() -> Vec<Box<dyn Strategy>> {
    vec![
        // Safe (9)
        Box::new(AcceptElicitation),
        Box::new(IfNoneMatchElicitation),
        Box::new(TrailingSlashElicitation),
        Box::new(CaseNormalizeElicitation),
        Box::new(LongUriElicitation),
        Box::new(AuthStripElicitation),
        Box::new(LowPrivilegeElicitation),
        Box::new(ScopeManipulationElicitation),
        Box::new(RateLimitHeadersElicitation),
        // MethodDestructive (5)
        Box::new(ContentTypeElicitation),
        Box::new(IfMatchElicitation),
        Box::new(EmptyBodyElicitation),
        Box::new(OversizedBodyElicitation),
        Box::new(StateTransitionElicitation),
        // OperationDestructive (3)
        Box::new(UniquenessElicitation),
        Box::new(DependencyDeleteElicitation),
        Box::new(RateLimitBurstElicitation),
    ]
}

/// Generates a filtered probe plan for `ctx`.
///
/// Iterates `all_strategies()`, skips any strategy where
/// `strategy.risk() > ctx.max_risk` or `!strategy.is_applicable(ctx)`, and
/// collects the union of all `generate()` results in declaration order.
#[must_use]
pub fn generate_plan(ctx: &ScanContext) -> Vec<ProbeSpec> {
    all_strategies()
        .iter()
        .filter(|s| s.risk() <= ctx.max_risk)
        .filter(|s| s.is_applicable(ctx))
        .flat_map(|s| s.generate(ctx))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::RiskLevel;
    use crate::{KnownDuplicate, ScanContext, StateField};
    use http::{HeaderMap, HeaderValue};

    fn make_minimal_ctx(max_risk: RiskLevel) -> ScanContext {
        ScanContext {
            target: "https://api.example.com/users/{id}".to_string(),
            baseline_id: "1001".to_string(),
            probe_id: "9999".to_string(),
            headers: HeaderMap::new(),
            max_risk,
            known_duplicate: None,
            state_field: None,
            alt_credential: None,
        }
    }

    fn make_full_ctx() -> ScanContext {
        let mut headers = HeaderMap::new();
        headers.insert(
            http::header::AUTHORIZATION,
            HeaderValue::from_static("Bearer token123"),
        );
        let mut alt = HeaderMap::new();
        alt.insert(
            http::header::AUTHORIZATION,
            HeaderValue::from_static("Bearer under-scoped"),
        );
        ScanContext {
            target: "https://api.example.com/users/{id}".to_string(),
            baseline_id: "1001".to_string(),
            probe_id: "9999".to_string(),
            headers,
            max_risk: RiskLevel::OperationDestructive,
            known_duplicate: Some(KnownDuplicate {
                field: "email".to_string(),
                value: "alice@example.com".to_string(),
            }),
            state_field: Some(StateField {
                field: "status".to_string(),
                value: "invalid_state".to_string(),
            }),
            alt_credential: Some(alt),
        }
    }

    fn spec_strategy_id(spec: &ProbeSpec) -> &str {
        match spec {
            ProbeSpec::Pair(p) => p.metadata.strategy_id,
            ProbeSpec::Burst(b) => b.metadata.strategy_id,
            ProbeSpec::HeaderDiff(p) => p.metadata.strategy_id,
        }
    }

    #[test]
    fn all_strategies_returns_exactly_17() {
        assert_eq!(all_strategies().len(), 17);
    }

    #[test]
    fn risk_distribution_is_9_5_3() {
        let s = all_strategies();
        let count = |r: RiskLevel| s.iter().filter(|x| x.risk() == r).count();
        assert_eq!(count(RiskLevel::Safe), 9);
        assert_eq!(count(RiskLevel::MethodDestructive), 5);
        assert_eq!(count(RiskLevel::OperationDestructive), 3);
    }

    #[test]
    fn all_strategy_ids_are_unique() {
        let strategies = all_strategies();
        let mut ids: Vec<&str> = strategies.iter().map(|s| s.id()).collect();
        ids.sort_unstable();
        let before = ids.len();
        ids.dedup();
        assert_eq!(before, ids.len(), "duplicate strategy IDs detected");
    }

    #[test]
    fn safe_filter_excludes_method_and_op_destructive() {
        let ctx = make_minimal_ctx(RiskLevel::Safe);
        let plan = generate_plan(&ctx);
        let safe_ids: std::collections::HashSet<&str> = all_strategies()
            .iter()
            .filter(|s| s.risk() == RiskLevel::Safe)
            .map(|s| s.id())
            .collect();
        for spec in &plan {
            assert!(
                safe_ids.contains(spec_strategy_id(spec)),
                "non-Safe strategy '{}' appeared in Safe plan",
                spec_strategy_id(spec)
            );
        }
    }

    #[test]
    fn method_destructive_ceiling_excludes_op_destructive() {
        let ctx = make_minimal_ctx(RiskLevel::MethodDestructive);
        let plan = generate_plan(&ctx);
        for spec in &plan {
            assert_ne!(
                spec_strategy_id(spec),
                "uniqueness-elicit",
                "OperationDestructive strategy appeared in MethodDestructive plan"
            );
            assert_ne!(
                spec_strategy_id(spec),
                "dependency-delete-elicit",
                "OperationDestructive strategy appeared in MethodDestructive plan"
            );
            assert_ne!(
                spec_strategy_id(spec),
                "rate-limit-burst-elicit",
                "OperationDestructive strategy appeared in MethodDestructive plan"
            );
        }
    }

    #[test]
    fn no_auth_excludes_auth_strip_and_low_privilege() {
        let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
        let plan = generate_plan(&ctx);
        for spec in &plan {
            assert_ne!(spec_strategy_id(spec), "auth-strip-elicit");
            assert_ne!(spec_strategy_id(spec), "low-privilege-elicit");
        }
    }

    #[test]
    fn no_alt_credential_excludes_scope_manipulation() {
        let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
        let plan = generate_plan(&ctx);
        for spec in &plan {
            assert_ne!(spec_strategy_id(spec), "scope-manipulation-elicit");
        }
    }

    #[test]
    fn no_state_field_excludes_state_transition() {
        let ctx = make_minimal_ctx(RiskLevel::MethodDestructive);
        let plan = generate_plan(&ctx);
        for spec in &plan {
            assert_ne!(spec_strategy_id(spec), "state-transition-elicit");
        }
    }

    #[test]
    fn no_known_duplicate_excludes_uniqueness() {
        let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
        let plan = generate_plan(&ctx);
        for spec in &plan {
            assert_ne!(spec_strategy_id(spec), "uniqueness-elicit");
        }
    }

    #[test]
    fn full_plan_returns_at_least_17_specs() {
        let ctx = make_full_ctx();
        let plan = generate_plan(&ctx);
        assert!(
            plan.len() >= 17,
            "expected >= 17 specs, got {}",
            plan.len()
        );
    }
}