parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! `AcceptElicitation` -- probes with an unsupported `Accept` media type.
//!
//! Sends `Accept: application/x-nonexistent` on both baseline and probe requests
//! so the only variable that differs across the pair is the resource ID. Per RFC
//! 9110 the server responds 406 for an existing resource whose representation
//! cannot satisfy the `Accept` header, and 404 for a nonexistent one — the
//! existence differential without body transmission.

use http::{HeaderMap, Method};
use parlov_core::{
    Applicability, NormativeStrength, OracleClass, ResponseSurface, SignalSurface, Technique,
    Vector,
};

use crate::strategy::Strategy;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::{build_pair, clone_headers_static};
use crate::ScanContext;

static METADATA: StrategyMetadata = StrategyMetadata {
    strategy_id: "accept-elicit",
    strategy_name: "Accept Elicitation",
    risk: RiskLevel::Safe,
};

/// Confirms the server performed content negotiation against the `Accept` header.
///
/// `Strong` when 406 is observed, OR `Vary` contains `Accept`, OR `Content-Type` differs
/// between baseline and probe. `Weak` when body bytes differ at the same status (negotiated
/// without surfacing the change in headers). `Missing` otherwise.
fn accept_applicable(baseline: &ResponseSurface, probe: &ResponseSurface) -> Applicability {
    if baseline.status.as_u16() == 406 || probe.status.as_u16() == 406 {
        return Applicability::Strong;
    }
    if vary_contains_accept(&baseline.headers) || vary_contains_accept(&probe.headers) {
        return Applicability::Strong;
    }
    if content_type_differs(&baseline.headers, &probe.headers) {
        return Applicability::Strong;
    }
    if baseline.status == probe.status && baseline.body != probe.body {
        return Applicability::Weak;
    }
    Applicability::Missing
}

fn vary_contains_accept(headers: &HeaderMap) -> bool {
    headers
        .get(http::header::VARY)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| {
            v.split(',')
                .any(|t| t.trim().eq_ignore_ascii_case("accept"))
        })
}

fn content_type_differs(b: &HeaderMap, p: &HeaderMap) -> bool {
    b.get(http::header::CONTENT_TYPE) != p.get(http::header::CONTENT_TYPE)
}

static TECHNIQUE: Technique = Technique {
    id: "accept",
    name: "Accept content negotiation",
    oracle_class: OracleClass::Existence,
    vector: Vector::StatusCodeDiff,
    strength: NormativeStrength::Should,
    normalization_weight: Some(0.05),
    inverted_signal_weight: None,
    method_relevant: false,
    parser_relevant: false,
    applicability: accept_applicable,
    contradiction_surface: SignalSurface::Status,
};

/// Elicits existence differentials via an unsupported `Accept` header applied symmetrically.
pub struct AcceptElicitation;

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

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

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

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

    fn generate(&self, ctx: &ScanContext) -> Vec<ProbeSpec> {
        let hdrs = clone_headers_static(&ctx.headers, "accept", "application/x-nonexistent");
        let mut specs = Vec::with_capacity(2);
        for method in [Method::GET, Method::HEAD] {
            let pair = build_pair(
                ctx,
                method,
                hdrs.clone(),
                hdrs.clone(),
                None,
                METADATA.clone(),
                TECHNIQUE,
            );
            specs.push(ProbeSpec::Pair(pair));
        }
        specs
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utils::minimal_ctx;
    use crate::types::ProbePair;
    use http::Method;

    const UNSUPPORTED_ACCEPT: &str = "application/x-nonexistent";

    fn find_pair_for<'a>(specs: &'a [ProbeSpec], method: &Method) -> &'a ProbePair {
        specs
            .iter()
            .find_map(|s| {
                if let ProbeSpec::Pair(p) = s {
                    if p.probe.method == *method {
                        return Some(p);
                    }
                }
                None
            })
            .expect("pair for method must exist")
    }

    #[test]
    fn risk_is_safe() {
        assert_eq!(AcceptElicitation.risk(), RiskLevel::Safe);
    }

    #[test]
    fn methods_contains_get_and_head() {
        let methods = AcceptElicitation.methods();
        assert_eq!(methods.len(), 2);
        assert!(methods.contains(&Method::GET));
        assert!(methods.contains(&Method::HEAD));
    }

    #[test]
    fn is_applicable_always_true() {
        assert!(AcceptElicitation.is_applicable(&minimal_ctx()));
    }

    #[test]
    fn generate_returns_two_items() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        assert_eq!(specs.len(), 2);
    }

    #[test]
    fn all_items_are_pair_variants() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        for spec in &specs {
            assert!(matches!(spec, ProbeSpec::Pair(_)));
        }
    }

    #[test]
    fn probe_has_unsupported_accept() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        let pair = find_pair_for(&specs, &Method::GET);
        assert_eq!(
            pair.probe.headers.get("accept").unwrap(),
            UNSUPPORTED_ACCEPT
        );
    }

    #[test]
    fn baseline_has_same_accept_as_probe() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        for method in [Method::GET, Method::HEAD] {
            let pair = find_pair_for(&specs, &method);
            assert_eq!(
                pair.baseline.headers.get("accept").unwrap(),
                UNSUPPORTED_ACCEPT,
                "baseline must carry Accept: {UNSUPPORTED_ACCEPT} for {method}; single-variable isolation"
            );
            assert_eq!(
                pair.baseline.headers.get("accept"),
                pair.probe.headers.get("accept"),
                "baseline and probe Accept values must match for {method}"
            );
        }
    }

    #[test]
    fn baseline_and_probe_headers_are_identical() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        for method in [Method::GET, Method::HEAD] {
            let pair = find_pair_for(&specs, &method);
            assert_eq!(
                pair.baseline.headers, pair.probe.headers,
                "headers must be byte-identical on both sides for {method}"
            );
        }
    }

    #[test]
    fn baseline_url_uses_baseline_id_probe_url_uses_probe_id() {
        let ctx = minimal_ctx();
        let specs = AcceptElicitation.generate(&ctx);
        for method in [Method::GET, Method::HEAD] {
            let pair = find_pair_for(&specs, &method);
            assert!(
                pair.baseline.url.contains(&ctx.baseline_id),
                "baseline url must embed baseline_id ({}) for {method}; got {}",
                ctx.baseline_id,
                pair.baseline.url
            );
            assert!(
                pair.probe.url.contains(&ctx.probe_id),
                "probe url must embed probe_id ({}) for {method}; got {}",
                ctx.probe_id,
                pair.probe.url
            );
        }
    }

    #[test]
    fn head_pair_probe_method_is_head() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        let pair = find_pair_for(&specs, &Method::HEAD);
        assert_eq!(pair.probe.method, Method::HEAD);
    }

    #[test]
    fn technique_vector_is_status_code_diff() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        assert_eq!(specs[0].technique().vector, Vector::StatusCodeDiff);
    }

    #[test]
    fn technique_strength_is_should() {
        let specs = AcceptElicitation.generate(&minimal_ctx());
        assert_eq!(specs[0].technique().strength, NormativeStrength::Should);
    }

    #[test]
    fn normalization_weight_is_0_05() {
        assert_eq!(TECHNIQUE.normalization_weight, Some(0.05));
    }

    #[test]
    fn inverted_signal_weight_is_none() {
        assert_eq!(TECHNIQUE.inverted_signal_weight, None);
    }
}