parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! `IfNoneMatchElicitation` -- probes with `If-None-Match: *`.
//!
//! Sends `If-None-Match: *` on both baseline and probe requests so the resource
//! ID is the only variable that differs across the pair. Per RFC 9110 S13.1.2
//! the server MUST respond 304 when a representation exists, and 404 for a
//! nonexistent resource โ€” the existence differential without body transmission.
//!
//! `IfNoneMatchElicitationProducer` and `IfNoneMatchElicitationConsumer` extend this
//! with Phase 2 chained probes: when Phase 1 captures a real `ETag` from a 2xx
//! response, the consumer re-issues `GET` and `HEAD` with `If-None-Match: <real-etag>`,
//! driving the 304-vs-404 differential with the server's own validator value.

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

use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::context::ScanContext;
use crate::harvest::EtagStrength;
use crate::strategy::Strategy;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::{build_pair, clone_headers_static, try_clone_headers_with};

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

/// Confirms the server evaluated the `If-None-Match` precondition.
///
/// `Strong` when either response carries `ETag` (the validator) OR when the status is
/// 304/412/428 (precondition response codes per RFC 9110). `Last-Modified` is NOT a marker
/// for entity-tag validators โ€” `If-None-Match` evaluates `ETag` only.
fn if_none_match_applicable(baseline: &ResponseSurface, probe: &ResponseSurface) -> Applicability {
    if has_etag(&baseline.headers) || has_etag(&probe.headers) {
        return Applicability::Strong;
    }
    if matches_precondition_status(baseline.status.as_u16())
        || matches_precondition_status(probe.status.as_u16())
    {
        return Applicability::Strong;
    }
    Applicability::Missing
}

fn has_etag(headers: &HeaderMap) -> bool {
    headers.contains_key(http::header::ETAG)
}

fn matches_precondition_status(s: u16) -> bool {
    matches!(s, 304 | 412 | 428)
}

static TECHNIQUE: Technique = Technique {
    id: "if-none-match",
    name: "If-None-Match conditional request",
    oracle_class: OracleClass::Existence,
    vector: Vector::StatusCodeDiff,
    strength: NormativeStrength::Must,
    normalization_weight: Some(0.12),
    inverted_signal_weight: None,
    method_relevant: false,
    parser_relevant: false,
    applicability: if_none_match_applicable,
    contradiction_surface: SignalSurface::Status,
};

/// Extracts an `ETag` from 2xx responses for `If-None-Match` chaining.
///
/// Admits any `ETag` strength. `If-None-Match` uses weak comparison per RFC 9110
/// ยง13.1.2, so both strong and weak `ETags` are valid inputs for chained probes.
pub(super) struct IfNoneMatchElicitationProducer;

impl Producer for IfNoneMatchElicitationProducer {
    /// Admits 2xx responses โ€” `ETag` is only meaningful on success.
    fn admits(&self, class: ResponseClass) -> bool {
        matches!(class, ResponseClass::Success)
    }

    /// Extracts the `ETag` header value with its weak/strong discriminant.
    fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
        let raw = headers.get(http::header::ETAG)?.to_str().ok()?;
        let strength = if raw.starts_with("W/") {
            EtagStrength::Weak
        } else {
            EtagStrength::Strong
        };
        Some(ProducerOutput::Etag(raw.to_owned(), strength))
    }
}

/// Converts a harvested `ETag` into `If-None-Match` chained probe specs for read methods.
///
/// Generates up to 2 specs โ€” one each for `GET` and `HEAD` โ€” using the real `ETag`
/// value. Neither method carries a body.
pub(super) struct IfNoneMatchElicitationConsumer;

impl Consumer for IfNoneMatchElicitationConsumer {
    fn needs(&self) -> ProducerOutputKind {
        ProducerOutputKind::Etag
    }

    fn generate(&self, ctx: &ScanContext, output: &ProducerOutput) -> Vec<ProbeSpec> {
        let ProducerOutput::Etag(etag, _) = output else {
            return vec![];
        };
        let mut specs = Vec::with_capacity(2);
        for method in [Method::GET, Method::HEAD] {
            let Some(hdrs) = try_clone_headers_with(&ctx.headers, "if-none-match", etag) else {
                continue;
            };
            let pair = build_pair(
                ctx,
                method,
                hdrs.clone(),
                hdrs,
                None,
                METADATA.clone(),
                TECHNIQUE,
            );
            specs.push(ProbeSpec::Pair(pair));
        }
        specs
    }
}

/// Elicits existence differentials via the `If-None-Match: *` conditional header.
pub struct IfNoneMatchElicitation;

impl Strategy for IfNoneMatchElicitation {
    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-none-match", "*");
        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::{HeaderValue, Method};

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

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

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

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

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

    #[test]
    fn baseline_and_probe_headers_are_identical() {
        let specs = IfNoneMatchElicitation.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 = IfNoneMatchElicitation.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 technique_strength_is_must() {
        let specs = IfNoneMatchElicitation.generate(&minimal_ctx());
        assert_eq!(specs[0].technique().strength, NormativeStrength::Must);
    }

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

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

    // --- IfNoneMatchElicitationProducer tests ---

    #[test]
    fn if_none_match_producer_admits_success_only() {
        let p = IfNoneMatchElicitationProducer;
        assert!(p.admits(ResponseClass::Success));
        assert!(!p.admits(ResponseClass::Redirect));
        assert!(!p.admits(ResponseClass::StructuredError));
        assert!(!p.admits(ResponseClass::Other));
        assert!(!p.admits(ResponseClass::PartialContent));
    }

    #[test]
    fn if_none_match_producer_extracts_etag() {
        let p = IfNoneMatchElicitationProducer;
        let mut headers = http::HeaderMap::new();
        headers.insert(http::header::ETAG, HeaderValue::from_static("\"xyz\""));
        let out = p.extract(ResponseClass::Success, &headers);
        assert!(
            matches!(out, Some(ProducerOutput::Etag(ref s, EtagStrength::Strong)) if s == "\"xyz\""),
            "expected Strong ETag; got {out:?}"
        );
    }

    #[test]
    fn if_none_match_producer_returns_none_when_no_etag() {
        let p = IfNoneMatchElicitationProducer;
        let headers = http::HeaderMap::new();
        assert!(p.extract(ResponseClass::Success, &headers).is_none());
    }

    // --- IfNoneMatchElicitationConsumer tests ---

    #[test]
    fn if_none_match_consumer_needs_etag() {
        assert_eq!(
            IfNoneMatchElicitationConsumer.needs(),
            ProducerOutputKind::Etag
        );
    }

    #[test]
    fn if_none_match_consumer_generates_two_specs() {
        let output = ProducerOutput::Etag("\"xyz\"".to_owned(), EtagStrength::Strong);
        let specs = IfNoneMatchElicitationConsumer.generate(&minimal_ctx(), &output);
        assert_eq!(
            specs.len(),
            2,
            "expected 2 specs (GET, HEAD); got {}",
            specs.len()
        );
        for spec in &specs {
            assert!(
                matches!(spec, ProbeSpec::Pair(_)),
                "each spec must be a Pair"
            );
        }
    }

    #[test]
    fn if_none_match_consumer_probe_headers_contain_real_etag() {
        let output = ProducerOutput::Etag("\"xyz\"".to_owned(), EtagStrength::Strong);
        let specs = IfNoneMatchElicitationConsumer.generate(&minimal_ctx(), &output);
        for spec in &specs {
            let ProbeSpec::Pair(pair) = spec else {
                panic!("expected Pair")
            };
            assert_eq!(
                pair.probe
                    .headers
                    .get("if-none-match")
                    .map(|v| v.to_str().unwrap_or("")),
                Some("\"xyz\""),
                "probe If-None-Match must be the real ETag for method {}",
                pair.probe.method
            );
        }
    }

    #[test]
    fn if_none_match_consumer_baseline_headers_match_probe_headers() {
        let output = ProducerOutput::Etag("\"xyz\"".to_owned(), EtagStrength::Strong);
        let specs = IfNoneMatchElicitationConsumer.generate(&minimal_ctx(), &output);
        for spec in &specs {
            let ProbeSpec::Pair(pair) = spec else {
                panic!("expected Pair")
            };
            assert_eq!(
                pair.baseline.headers.get("if-none-match"),
                pair.probe.headers.get("if-none-match"),
                "baseline and probe If-None-Match must be equal for {} (single-variable isolation)",
                pair.probe.method
            );
        }
    }

    #[test]
    fn if_none_match_consumer_no_body_on_any_spec() {
        let output = ProducerOutput::Etag("\"xyz\"".to_owned(), EtagStrength::Strong);
        let specs = IfNoneMatchElicitationConsumer.generate(&minimal_ctx(), &output);
        for spec in &specs {
            let ProbeSpec::Pair(pair) = spec else {
                panic!("expected Pair")
            };
            assert!(
                pair.probe.body.is_none(),
                "{} probe must have no body",
                pair.probe.method
            );
            assert!(
                pair.baseline.body.is_none(),
                "{} baseline must have no body",
                pair.baseline.method
            );
        }
    }
}