parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! `IfMatchReadElicitation` -- probes GET and HEAD with a bogus `If-Match` precondition.
//!
//! Sends `If-Match: "zz-if-match-elicit-bogus-etag-000000000000"` on both baseline
//! and probe requests so the resource ID is the only variable across the pair.
//! Per RFC 9110 §13.1.1, for an existing resource whose `ETag` does not match the
//! precondition the server MUST respond 412; for a nonexistent resource it returns
//! 404 before precondition evaluation — the existence differential.
//!
//! Uses GET and HEAD only (`RiskLevel::Safe`). Write-method variants are provided
//! by `IfMatchElicitation` (`RiskLevel::MethodDestructive`).

use http::Method;
use parlov_core::Technique;

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

use super::if_match::TECHNIQUE as IF_MATCH_TECHNIQUE;

/// Bogus strong `ETag` that cannot collide with any real resource `ETag`.
const BOGUS_ETAG: &str = "\"zz-if-match-elicit-bogus-etag-000000000000\"";

static METADATA: StrategyMetadata = StrategyMetadata {
    strategy_id: "if-match-read-elicit",
    strategy_name: "If-Match Read Elicitation",
    risk: RiskLevel::Safe,
};

/// Shared technique definition: reuses the `if-match` technique from the write-method strategy.
///
/// The technique is identical — both strategies probe the same RFC 9110 §13.1.1 precondition
/// evaluation path; only the HTTP methods and risk level differ.
static TECHNIQUE: Technique = IF_MATCH_TECHNIQUE;

/// Elicits existence differentials via an `If-Match` precondition header on read methods.
pub struct IfMatchReadElicitation;

impl Strategy for IfMatchReadElicitation {
    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, "if-match", BOGUS_ETAG);
        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 parlov_core::NormativeStrength;

    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!(IfMatchReadElicitation.risk(), RiskLevel::Safe);
    }

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

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

    #[test]
    fn methods_are_get_and_head() {
        let specs = IfMatchReadElicitation.generate(&minimal_ctx());
        let methods: Vec<&Method> = specs
            .iter()
            .filter_map(|s| {
                if let ProbeSpec::Pair(p) = s {
                    Some(&p.probe.method)
                } else {
                    None
                }
            })
            .collect();
        assert!(methods.contains(&&Method::GET), "GET must be present");
        assert!(methods.contains(&&Method::HEAD), "HEAD must be present");
    }

    #[test]
    fn probe_has_if_match_bogus_etag() {
        let specs = IfMatchReadElicitation.generate(&minimal_ctx());
        let pair = find_pair_for(&specs, &Method::GET);
        assert_eq!(pair.probe.headers.get("if-match").unwrap(), BOGUS_ETAG);
    }

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

    #[test]
    fn baseline_and_probe_headers_are_identical() {
        let specs = IfMatchReadElicitation.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 neither_side_has_body() {
        let specs = IfMatchReadElicitation.generate(&minimal_ctx());
        for method in [Method::GET, Method::HEAD] {
            let pair = find_pair_for(&specs, &method);
            assert!(
                pair.probe.body.is_none(),
                "{method} probe must have no body"
            );
            assert!(
                pair.baseline.body.is_none(),
                "{method} baseline must have no body"
            );
        }
    }

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

    #[test]
    fn strategy_id_is_correct() {
        assert_eq!(IfMatchReadElicitation.id(), "if-match-read-elicit");
    }
}