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, json_body, try_clone_headers_with};
static METADATA: StrategyMetadata = StrategyMetadata {
strategy_id: "if-match-elicit",
strategy_name: "If-Match Elicitation",
risk: RiskLevel::MethodDestructive,
};
fn if_match_applicable(baseline: &ResponseSurface, probe: &ResponseSurface) -> Applicability {
let has_etag = |h: &HeaderMap| h.contains_key(http::header::ETAG);
if has_etag(&baseline.headers) || has_etag(&probe.headers) {
return Applicability::Strong;
}
let is_precondition_status = |s: u16| matches!(s, 412 | 428);
if is_precondition_status(baseline.status.as_u16())
|| is_precondition_status(probe.status.as_u16())
{
return Applicability::Strong;
}
Applicability::Missing
}
pub(super) static TECHNIQUE: Technique = Technique {
id: "if-match",
name: "If-Match precondition probe",
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_match_applicable,
contradiction_surface: SignalSurface::Status,
};
pub(super) struct IfMatchElicitationProducer;
impl Producer for IfMatchElicitationProducer {
fn admits(&self, class: ResponseClass) -> bool {
matches!(class, ResponseClass::Success)
}
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))
}
}
pub(super) struct IfMatchElicitationConsumer;
impl Consumer for IfMatchElicitationConsumer {
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(3);
for method in [Method::PUT, Method::PATCH, Method::DELETE] {
let Some(hdrs) = try_clone_headers_with(&ctx.headers, "if-match", etag) else {
continue;
};
let body = if method == Method::DELETE {
None
} else {
Some(json_body(&[]))
};
let pair = build_pair(
ctx,
method,
hdrs.clone(),
hdrs,
body,
METADATA.clone(),
TECHNIQUE,
);
specs.push(ProbeSpec::Pair(pair));
}
specs
}
}
pub struct IfMatchElicitation;
impl Strategy for IfMatchElicitation {
fn metadata(&self) -> &'static StrategyMetadata {
&METADATA
}
fn technique_def(&self) -> &'static Technique {
&TECHNIQUE
}
fn methods(&self) -> &[Method] {
&[Method::PUT, Method::PATCH, Method::DELETE]
}
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(3);
for method in [Method::PUT, Method::PATCH, Method::DELETE] {
let body = if method == Method::DELETE {
None
} else {
Some(json_body(&[]))
};
let pair = build_pair(
ctx,
method,
hdrs.clone(),
hdrs.clone(),
body,
METADATA.clone(),
TECHNIQUE,
);
specs.push(ProbeSpec::Pair(pair));
}
specs
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{ctx_method_destructive, minimal_ctx};
use crate::types::ProbePair;
use http::{HeaderValue, Method};
const BOGUS_ETAG: &str = "\"bogus-etag\"";
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_method_destructive() {
assert_eq!(IfMatchElicitation.risk(), RiskLevel::MethodDestructive);
}
#[test]
fn generate_returns_three_items() {
assert_eq!(
IfMatchElicitation.generate(&ctx_method_destructive()).len(),
3
);
}
#[test]
fn probe_has_if_match_bogus_etag() {
let specs = IfMatchElicitation.generate(&ctx_method_destructive());
let ProbeSpec::Pair(pair) = &specs[0] else {
panic!("expected Pair")
};
assert_eq!(pair.probe.headers.get("if-match").unwrap(), BOGUS_ETAG);
}
#[test]
fn baseline_has_same_if_match_as_probe() {
let specs = IfMatchElicitation.generate(&ctx_method_destructive());
for method in [Method::PUT, Method::PATCH, Method::DELETE] {
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 = IfMatchElicitation.generate(&ctx_method_destructive());
for method in [Method::PUT, Method::PATCH, Method::DELETE] {
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 = ctx_method_destructive();
let specs = IfMatchElicitation.generate(&ctx);
for method in [Method::PUT, Method::PATCH, Method::DELETE] {
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 delete_pair_probe_has_no_body() {
let specs = IfMatchElicitation.generate(&ctx_method_destructive());
let pair = find_pair_for(&specs, &Method::DELETE);
assert!(pair.probe.body.is_none());
assert!(pair.baseline.body.is_none());
}
#[test]
fn technique_strength_is_must() {
let specs = IfMatchElicitation.generate(&ctx_method_destructive());
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);
}
#[test]
fn if_match_producer_admits_success_only() {
let p = IfMatchElicitationProducer;
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_match_producer_extracts_etag_strong() {
let p = IfMatchElicitationProducer;
let mut headers = http::HeaderMap::new();
headers.insert(http::header::ETAG, HeaderValue::from_static("\"abc\""));
let out = p.extract(ResponseClass::Success, &headers);
assert!(
matches!(out, Some(ProducerOutput::Etag(ref s, EtagStrength::Strong)) if s == "\"abc\""),
"expected Strong ETag; got {out:?}"
);
}
#[test]
fn if_match_producer_extracts_etag_weak() {
let p = IfMatchElicitationProducer;
let mut headers = http::HeaderMap::new();
headers.insert(http::header::ETAG, HeaderValue::from_static("W/\"abc\""));
let out = p.extract(ResponseClass::Success, &headers);
assert!(
matches!(out, Some(ProducerOutput::Etag(ref s, EtagStrength::Weak)) if s == "W/\"abc\""),
"expected Weak ETag; got {out:?}"
);
}
#[test]
fn if_match_producer_returns_none_when_no_etag() {
let p = IfMatchElicitationProducer;
let headers = http::HeaderMap::new();
assert!(p.extract(ResponseClass::Success, &headers).is_none());
}
#[test]
fn if_match_consumer_needs_etag() {
assert_eq!(IfMatchElicitationConsumer.needs(), ProducerOutputKind::Etag);
}
#[test]
fn if_match_consumer_generates_three_specs_from_strong_etag() {
let output = ProducerOutput::Etag("\"v1\"".to_owned(), EtagStrength::Strong);
let specs = IfMatchElicitationConsumer.generate(&ctx_method_destructive(), &output);
assert_eq!(
specs.len(),
3,
"expected 3 specs (PUT, PATCH, DELETE); got {}",
specs.len()
);
}
#[test]
fn if_match_consumer_probe_headers_contain_real_etag() {
let output = ProducerOutput::Etag("\"v1\"".to_owned(), EtagStrength::Strong);
let specs = IfMatchElicitationConsumer.generate(&ctx_method_destructive(), &output);
for spec in &specs {
let ProbeSpec::Pair(pair) = spec else {
panic!("expected Pair")
};
assert_eq!(
pair.probe
.headers
.get("if-match")
.map(|v| v.to_str().unwrap_or("")),
Some("\"v1\""),
"probe If-Match must be the real ETag for method {}",
pair.probe.method
);
}
}
#[test]
fn if_match_consumer_baseline_headers_match_probe_headers() {
let output = ProducerOutput::Etag("\"v1\"".to_owned(), EtagStrength::Strong);
let specs = IfMatchElicitationConsumer.generate(&ctx_method_destructive(), &output);
for spec in &specs {
let ProbeSpec::Pair(pair) = spec else {
panic!("expected Pair")
};
assert_eq!(
pair.baseline.headers.get("if-match"),
pair.probe.headers.get("if-match"),
"baseline and probe If-Match must be equal for {} (single-variable isolation)",
pair.probe.method
);
}
}
#[test]
fn if_match_consumer_delete_has_no_body() {
let output = ProducerOutput::Etag("\"v1\"".to_owned(), EtagStrength::Strong);
let specs = IfMatchElicitationConsumer.generate(&ctx_method_destructive(), &output);
let delete_pair = specs
.iter()
.find_map(|s| {
if let ProbeSpec::Pair(p) = s {
if p.probe.method == Method::DELETE {
return Some(p);
}
}
None
})
.expect("DELETE spec must exist");
assert!(
delete_pair.probe.body.is_none(),
"DELETE probe must have no body"
);
assert!(
delete_pair.baseline.body.is_none(),
"DELETE baseline must have no body"
);
}
#[test]
fn if_match_consumer_put_has_body() {
let output = ProducerOutput::Etag("\"v1\"".to_owned(), EtagStrength::Strong);
let specs = IfMatchElicitationConsumer.generate(&ctx_method_destructive(), &output);
let put_pair = specs
.iter()
.find_map(|s| {
if let ProbeSpec::Pair(p) = s {
if p.probe.method == Method::PUT {
return Some(p);
}
}
None
})
.expect("PUT spec must exist");
assert!(put_pair.probe.body.is_some(), "PUT probe must have a body");
}
#[test]
fn if_match_consumer_generates_nothing_for_non_etag_output() {
let output = ProducerOutput::Location("https://example.com".to_owned());
let specs = IfMatchElicitationConsumer.generate(&minimal_ctx(), &output);
assert!(specs.is_empty(), "non-Etag output must produce no specs");
}
}