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,
};
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,
};
pub(super) struct IfNoneMatchElicitationProducer;
impl Producer for IfNoneMatchElicitationProducer {
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 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
}
}
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);
}
#[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());
}
#[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
);
}
}
}