parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! `ScdResourceIdProducer` and `ScdResourceIdConsumer` — B4 resource-identifier chains.
//!
//! POST/PUT baseline that returns 201 + `Location: /items/42` → replay GET, PATCH, DELETE
//! against the created resource ID. Producer extracts the last non-empty path segment from
//! the `Location` header; consumer substitutes it as the baseline URL for chained probes.
//!
//! `RESTler`'s core contribution: grounds absence probes on a resource the scanner itself
//! created, eliminating the "did this ID ever exist" confound.

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

use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::context::ScanContext;
use crate::types::{ProbePair, ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::substitute_url;
use parlov_core::ProbeDefinition;

static METADATA: StrategyMetadata = StrategyMetadata {
    strategy_id: "scd-resource-id-chain",
    strategy_name: "Resource-ID Chain (POST/PUT → GET/PATCH/DELETE)",
    risk: RiskLevel::MethodDestructive,
};

static TECHNIQUE: Technique = Technique {
    id: "resource-id-chain",
    name: "Resource-identifier producer/consumer chain",
    oracle_class: OracleClass::Existence,
    vector: Vector::StatusCodeDiff,
    strength: NormativeStrength::Should,
    normalization_weight: Some(0.12),
    inverted_signal_weight: None,
    method_relevant: false,
    parser_relevant: false,
    applicability: always_applicable,
    contradiction_surface: SignalSurface::Status,
};

/// Extracts a resource ID from the `Location` header of 2xx responses.
///
/// Admits only `Success` (2xx) — 201 Created is the canonical signal. Extracts
/// the last non-empty path segment of the `Location` value as the resource ID.
pub(super) struct ScdResourceIdProducer;

impl Producer for ScdResourceIdProducer {
    fn admits(&self, class: ResponseClass) -> bool {
        matches!(class, ResponseClass::Success)
    }

    /// Extracts the last path segment of the `Location` header as a resource ID.
    ///
    /// Returns `None` when the header is absent, malformed, the path is just `/`,
    /// or the path ends with a trailing slash (empty final segment).
    fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
        let location = headers.get(http::header::LOCATION)?.to_str().ok()?;
        let path = location.split('?').next().unwrap_or(location);
        // rsplit gives the true last segment; empty means trailing slash or root.
        let segment = path.rsplit('/').next()?;
        if segment.is_empty() {
            return None;
        }
        Some(ProducerOutput::ResourceId(segment.to_owned()))
    }
}

/// Converts a harvested resource ID into GET, PATCH, DELETE chained probe specs.
///
/// The extracted ID becomes the baseline URL (the scanner created this resource and
/// knows it exists). `ctx.probe_id` remains the probe URL (nonexistent). This
/// preserves the single-variable differential: only the resource ID differs.
pub(super) struct ScdResourceIdConsumer;

impl Consumer for ScdResourceIdConsumer {
    fn needs(&self) -> ProducerOutputKind {
        ProducerOutputKind::ResourceId
    }

    fn generate(&self, ctx: &ScanContext, output: &ProducerOutput) -> Vec<ProbeSpec> {
        let ProducerOutput::ResourceId(ref id) = output else {
            return vec![];
        };
        let baseline_url = substitute_url(&ctx.target, id);
        let probe_url = substitute_url(&ctx.target, &ctx.probe_id);
        let mut specs = Vec::with_capacity(3);
        for method in [Method::GET, Method::PATCH, Method::DELETE] {
            let body = if method == Method::PATCH {
                Some(bytes::Bytes::from_static(b"{}"))
            } else {
                None
            };
            let pair = ProbePair {
                baseline: ProbeDefinition {
                    url: baseline_url.clone(),
                    method: method.clone(),
                    headers: ctx.headers.clone(),
                    body: body.clone(),
                },
                probe: ProbeDefinition {
                    url: probe_url.clone(),
                    method,
                    headers: ctx.headers.clone(),
                    body,
                },
                canonical_baseline: None,
                metadata: METADATA.clone(),
                technique: TECHNIQUE,
                chain_provenance: None,
            };
            specs.push(ProbeSpec::Pair(pair));
        }
        specs
    }
}

#[cfg(test)]
#[path = "resource_id_tests.rs"]
mod tests;