use http::{HeaderMap, Method};
use parlov_core::{
Applicability, NormativeStrength, OracleClass, ResponseSurface, SignalSurface, Technique,
Vector,
};
use crate::strategy::Strategy;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::{build_pair, clone_headers_static};
use crate::ScanContext;
static METADATA: StrategyMetadata = StrategyMetadata {
strategy_id: "accept-elicit",
strategy_name: "Accept Elicitation",
risk: RiskLevel::Safe,
};
fn accept_applicable(baseline: &ResponseSurface, probe: &ResponseSurface) -> Applicability {
if baseline.status.as_u16() == 406 || probe.status.as_u16() == 406 {
return Applicability::Strong;
}
if vary_contains_accept(&baseline.headers) || vary_contains_accept(&probe.headers) {
return Applicability::Strong;
}
if content_type_differs(&baseline.headers, &probe.headers) {
return Applicability::Strong;
}
if baseline.status == probe.status && baseline.body != probe.body {
return Applicability::Weak;
}
Applicability::Missing
}
fn vary_contains_accept(headers: &HeaderMap) -> bool {
headers
.get(http::header::VARY)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(',')
.any(|t| t.trim().eq_ignore_ascii_case("accept"))
})
}
fn content_type_differs(b: &HeaderMap, p: &HeaderMap) -> bool {
b.get(http::header::CONTENT_TYPE) != p.get(http::header::CONTENT_TYPE)
}
static TECHNIQUE: Technique = Technique {
id: "accept",
name: "Accept content negotiation",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Should,
normalization_weight: Some(0.05),
inverted_signal_weight: None,
method_relevant: false,
parser_relevant: false,
applicability: accept_applicable,
contradiction_surface: SignalSurface::Status,
};
pub struct AcceptElicitation;
impl Strategy for AcceptElicitation {
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, "accept", "application/x-nonexistent");
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::Method;
const UNSUPPORTED_ACCEPT: &str = "application/x-nonexistent";
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!(AcceptElicitation.risk(), RiskLevel::Safe);
}
#[test]
fn methods_contains_get_and_head() {
let methods = AcceptElicitation.methods();
assert_eq!(methods.len(), 2);
assert!(methods.contains(&Method::GET));
assert!(methods.contains(&Method::HEAD));
}
#[test]
fn is_applicable_always_true() {
assert!(AcceptElicitation.is_applicable(&minimal_ctx()));
}
#[test]
fn generate_returns_two_items() {
let specs = AcceptElicitation.generate(&minimal_ctx());
assert_eq!(specs.len(), 2);
}
#[test]
fn all_items_are_pair_variants() {
let specs = AcceptElicitation.generate(&minimal_ctx());
for spec in &specs {
assert!(matches!(spec, ProbeSpec::Pair(_)));
}
}
#[test]
fn probe_has_unsupported_accept() {
let specs = AcceptElicitation.generate(&minimal_ctx());
let pair = find_pair_for(&specs, &Method::GET);
assert_eq!(
pair.probe.headers.get("accept").unwrap(),
UNSUPPORTED_ACCEPT
);
}
#[test]
fn baseline_has_same_accept_as_probe() {
let specs = AcceptElicitation.generate(&minimal_ctx());
for method in [Method::GET, Method::HEAD] {
let pair = find_pair_for(&specs, &method);
assert_eq!(
pair.baseline.headers.get("accept").unwrap(),
UNSUPPORTED_ACCEPT,
"baseline must carry Accept: {UNSUPPORTED_ACCEPT} for {method}; single-variable isolation"
);
assert_eq!(
pair.baseline.headers.get("accept"),
pair.probe.headers.get("accept"),
"baseline and probe Accept values must match for {method}"
);
}
}
#[test]
fn baseline_and_probe_headers_are_identical() {
let specs = AcceptElicitation.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 = AcceptElicitation.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 head_pair_probe_method_is_head() {
let specs = AcceptElicitation.generate(&minimal_ctx());
let pair = find_pair_for(&specs, &Method::HEAD);
assert_eq!(pair.probe.method, Method::HEAD);
}
#[test]
fn technique_vector_is_status_code_diff() {
let specs = AcceptElicitation.generate(&minimal_ctx());
assert_eq!(specs[0].technique().vector, Vector::StatusCodeDiff);
}
#[test]
fn technique_strength_is_should() {
let specs = AcceptElicitation.generate(&minimal_ctx());
assert_eq!(specs[0].technique().strength, NormativeStrength::Should);
}
#[test]
fn normalization_weight_is_0_05() {
assert_eq!(TECHNIQUE.normalization_weight, Some(0.05));
}
#[test]
fn inverted_signal_weight_is_none() {
assert_eq!(TECHNIQUE.inverted_signal_weight, None);
}
}