parlov-elicit 0.4.0

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::existence::cache_probing::{
    CpAccept, CpIfMatch, CpIfModifiedSince, CpIfNoneMatch, CpIfRange,
    CpIfUnmodifiedSince, CpRangeSatisfiable, CpRangeUnsatisfiable,
};
use crate::existence::error_message_granularity::{
    EmgAppVsServer404, EmgBola, EmgFkViolation, EmgQueryValidation,
    EmgSchemaValidationPatch, EmgSchemaValidationPut, EmgStateConflict,
};
use crate::existence::redirect_diff::{
    RdCaseVariation, RdDoubleSlash, RdPercentEncoding, RdPostTo303,
    RdProtocolUpgrade, RdPutTo303, RdSlashAppend, RdSlashStrip,
};
use crate::existence::status_code_diff::{
    AcceptElicitation, AuthStripElicitation, CaseNormalizeElicitation,
    ContentTypeElicitation, DependencyDeleteElicitation, EmptyBodyElicitation,
    IfMatchElicitation, IfNoneMatchElicitation, LongUriElicitation,
    LowPrivilegeElicitation, OversizedBodyElicitation, RateLimitBurstElicitation,
    RateLimitHeadersElicitation, ScopeManipulationElicitation,
    StateTransitionElicitation, TrailingSlashElicitation, UniquenessElicitation,
};
use crate::strategy::Strategy;
use crate::types::ProbeSpec;

/// Returns all 40 registered elicitation strategies.
///
/// Grouped by risk level in declaration order: 26 Safe (9 status-code-diff +
/// 8 cache-probing + 3 error-message-granularity + 6 redirect-diff),
/// 11 `MethodDestructive` (5 status-code-diff + 4 error-message-granularity +
/// 2 redirect-diff), 3 `OperationDestructive`.
/// The scheduler may reorder by risk or method.
#[must_use]
pub fn all_strategies() -> Vec<Box<dyn Strategy>> {
    vec![
        // Safe — status-code-diff (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),
        // Safe — cache-probing (8)
        Box::new(CpIfNoneMatch),
        Box::new(CpIfModifiedSince),
        Box::new(CpIfMatch),
        Box::new(CpIfUnmodifiedSince),
        Box::new(CpRangeSatisfiable),
        Box::new(CpRangeUnsatisfiable),
        Box::new(CpIfRange),
        Box::new(CpAccept),
        // Safe — error-message-granularity (3)
        Box::new(EmgBola),
        Box::new(EmgQueryValidation),
        Box::new(EmgAppVsServer404),
        // Safe — redirect-diff (6)
        Box::new(RdSlashAppend),
        Box::new(RdSlashStrip),
        Box::new(RdCaseVariation),
        Box::new(RdDoubleSlash),
        Box::new(RdPercentEncoding),
        Box::new(RdProtocolUpgrade),
        // MethodDestructive — status-code-diff (5)
        Box::new(ContentTypeElicitation),
        Box::new(IfMatchElicitation),
        Box::new(EmptyBodyElicitation),
        Box::new(OversizedBodyElicitation),
        Box::new(StateTransitionElicitation),
        // MethodDestructive — error-message-granularity (4)
        Box::new(EmgSchemaValidationPatch),
        Box::new(EmgSchemaValidationPut),
        Box::new(EmgStateConflict),
        Box::new(EmgFkViolation),
        // MethodDestructive — redirect-diff (2)
        Box::new(RdPostTo303),
        Box::new(RdPutTo303),
        // 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};
    use parlov_core::Vector;

    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,
            body_template: 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),
            body_template: None,
        }
    }

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

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

    #[test]
    fn risk_distribution_is_26_11_3() {
        let s = all_strategies();
        let count = |r: RiskLevel| s.iter().filter(|x| x.risk() == r).count();
        assert_eq!(count(RiskLevel::Safe), 26);
        assert_eq!(count(RiskLevel::MethodDestructive), 11);
        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_42_specs() {
        let ctx = make_full_ctx();
        let plan = generate_plan(&ctx);
        // 43 total strategies minus EmgStateConflict (requires body_template) = 42
        // strategies; GET/HEAD strategies produce 2 specs each so actual count is higher
        assert!(
            plan.len() >= 42,
            "expected >= 42 specs, got {}",
            plan.len()
        );
    }

    // --- Technique metadata tests ---

    #[test]
    fn all_specs_have_known_vector() {
        let ctx = make_full_ctx();
        let plan = generate_plan(&ctx);
        for spec in &plan {
            let v = spec.technique().vector;
            assert!(
                v == Vector::StatusCodeDiff
                    || v == Vector::CacheProbing
                    || v == Vector::ErrorMessageGranularity
                    || v == Vector::RedirectDiff,
                "strategy '{}' has unexpected vector: {:?}",
                spec_strategy_id(spec),
                v,
            );
        }
    }

    #[test]
    fn all_specs_have_populated_technique_id() {
        let ctx = make_full_ctx();
        let plan = generate_plan(&ctx);
        for spec in &plan {
            assert!(
                !spec.technique().id.is_empty(),
                "strategy '{}' has empty technique id",
                spec_strategy_id(spec),
            );
        }
    }

}