parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! `CpRangeSatisfiable` -- satisfiable byte-range request.
//!
//! Sends `Range: bytes=0-0` on both baseline and probe. Per RFC 9110 S14.2 a
//! server SHOULD respond 206 with the first byte when the range is satisfiable.
//! A nonexistent resource returns 404, producing the differential.
//!
//! Also exports `CpRangeSizeProducer`, a shared producer used by both the
//! satisfiable and unsatisfiable range consumers in phase 2 chaining.

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::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: "cp-range",
    strategy_name: "Cache Probe: Range (satisfiable)",
    risk: RiskLevel::Safe,
};

static TECHNIQUE: Technique = Technique {
    id: "cp-range",
    name: "Satisfiable byte-range request",
    oracle_class: OracleClass::Existence,
    vector: Vector::CacheProbing,
    strength: NormativeStrength::Should,
    normalization_weight: None,
    inverted_signal_weight: None,
    method_relevant: false,
    parser_relevant: false,
    applicability: always_applicable,
    contradiction_surface: SignalSurface::Status,
};

/// Extracts total resource size from `Content-Range` on 206 and 416 responses.
///
/// Shared by both satisfiable and unsatisfiable range consumers. Returns `None`
/// when `Accept-Ranges: none` is present, when `Content-Range` is absent or
/// malformed, or when the parsed size is zero.
pub(super) struct CpRangeSizeProducer;

impl Producer for CpRangeSizeProducer {
    /// Admits 206 (`PartialContent`) and 416 (`RangeNotSatisfiable`) responses.
    fn admits(&self, class: ResponseClass) -> bool {
        matches!(
            class,
            ResponseClass::PartialContent | ResponseClass::RangeNotSatisfiable
        )
    }

    /// Parses total size from `Content-Range`, e.g. `bytes 0-0/1024` or `bytes */1024`.
    fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
        if let Some(ar) = headers.get(http::header::ACCEPT_RANGES) {
            if ar.as_bytes().eq_ignore_ascii_case(b"none") {
                return None;
            }
        }
        let raw = headers.get(http::header::CONTENT_RANGE)?.to_str().ok()?;
        let size_str = raw.rsplit('/').next()?;
        let size: u64 = size_str.trim().parse().ok()?;
        if size == 0 {
            return None;
        }
        Some(ProducerOutput::ContentRangeSize(size))
    }
}

/// Converts `ContentRangeSize` into satisfiable range probe specs.
///
/// Generates two `Range` headers: one covering the full resource (`bytes=0-(size-1)`)
/// and one requesting only the last byte (`bytes=(size-1)-(size-1)`).
pub(super) struct CpRangeSatisfiableConsumer;

impl Consumer for CpRangeSatisfiableConsumer {
    fn needs(&self) -> ProducerOutputKind {
        ProducerOutputKind::ContentRangeSize
    }

    fn generate(&self, ctx: &ScanContext, output: &ProducerOutput) -> Vec<ProbeSpec> {
        let ProducerOutput::ContentRangeSize(size) = output else {
            return vec![];
        };
        let full = format!("bytes=0-{}", size - 1);
        let last = format!("bytes={0}-{0}", size - 1);
        build_range_specs(ctx, [full, last])
    }
}

/// Builds `ProbeSpec::Pair` entries for each `range_val` string.
fn build_range_specs(ctx: &ScanContext, range_vals: [String; 2]) -> Vec<ProbeSpec> {
    let mut specs = Vec::with_capacity(2);
    for range_val in range_vals {
        let Some(hdrs) = try_clone_headers_with(&ctx.headers, "range", &range_val) else {
            continue;
        };
        let pair = build_pair(
            ctx,
            Method::GET,
            hdrs.clone(),
            hdrs,
            None,
            METADATA.clone(),
            TECHNIQUE,
        );
        specs.push(ProbeSpec::Pair(pair));
    }
    specs
}

/// Elicits existence differentials via `Range: bytes=0-0`.
pub struct CpRangeSatisfiable;

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

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

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

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

    fn generate(&self, ctx: &ScanContext) -> Vec<ProbeSpec> {
        let hdrs = clone_headers_static(&ctx.headers, "range", "bytes=0-0");
        let pair = build_pair(
            ctx,
            Method::GET,
            hdrs.clone(),
            hdrs,
            None,
            METADATA.clone(),
            TECHNIQUE,
        );
        vec![ProbeSpec::Pair(pair)]
    }
}

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