use bytes::Bytes;
use http::{HeaderMap, Method};
use parlov_core::{
always_applicable, NormativeStrength, OracleClass, ProbeDefinition, ResponseClass,
SignalSurface, Technique, Vector,
};
use serde_json::Value;
use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::context::ScanContext;
use crate::types::{ProbePair, ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::substitute_url;
static METADATA: StrategyMetadata = StrategyMetadata {
strategy_id: "scd-problem-details-chain",
strategy_name: "Problem-Details Chain (4xx → repaired POST body)",
risk: RiskLevel::Safe,
};
static TECHNIQUE: Technique = Technique {
id: "problem-details-chain",
name: "Structured-error producer/consumer chain",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Should,
normalization_weight: None,
inverted_signal_weight: None,
method_relevant: false,
parser_relevant: false,
applicability: always_applicable,
contradiction_surface: SignalSurface::Body,
};
pub(super) struct ScdProblemDetailsProducer;
impl Producer for ScdProblemDetailsProducer {
fn admits(&self, class: ResponseClass) -> bool {
matches!(class, ResponseClass::StructuredError)
}
fn extract(&self, _class: ResponseClass, _headers: &HeaderMap) -> Option<ProducerOutput> {
None
}
fn extract_with_body(
&self,
class: ResponseClass,
headers: &HeaderMap,
body: &Bytes,
) -> Option<ProducerOutput> {
if !self.admits(class) {
return None;
}
if !has_json_content_type(headers) {
return None;
}
if body.is_empty() {
return None;
}
let json: Value = serde_json::from_slice(body).ok()?;
let required_fields = extract_error_fields(&json);
let error_type = json
.get("type")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
if required_fields.is_empty() && error_type.is_none() {
return None;
}
Some(ProducerOutput::ProblemDetails {
required_fields,
error_type,
})
}
}
pub(super) struct ScdProblemDetailsConsumer;
impl Consumer for ScdProblemDetailsConsumer {
fn needs(&self) -> ProducerOutputKind {
ProducerOutputKind::ProblemDetails
}
fn generate(&self, ctx: &ScanContext, output: &ProducerOutput) -> Vec<ProbeSpec> {
let ProducerOutput::ProblemDetails {
required_fields, ..
} = output
else {
return vec![];
};
if required_fields.is_empty() {
return vec![];
}
let body = build_minimal_body(required_fields);
let baseline_url = substitute_url(&ctx.target, &ctx.baseline_id);
let probe_url = substitute_url(&ctx.target, &ctx.probe_id);
let pair = ProbePair {
baseline: ProbeDefinition {
url: baseline_url,
method: Method::POST,
headers: ctx.headers.clone(),
body: Some(body.clone()),
},
probe: ProbeDefinition {
url: probe_url,
method: Method::POST,
headers: ctx.headers.clone(),
body: Some(body),
},
canonical_baseline: None,
metadata: METADATA.clone(),
technique: TECHNIQUE,
chain_provenance: None,
};
vec![ProbeSpec::Pair(pair)]
}
}
fn has_json_content_type(headers: &HeaderMap) -> bool {
headers
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| {
ct.contains("application/problem+json") || ct.contains("application/json")
})
}
fn extract_error_fields(json: &Value) -> Vec<String> {
let Some(errors) = json.get("errors").and_then(|v| v.as_array()) else {
return vec![];
};
errors
.iter()
.filter_map(|entry| entry.get("field")?.as_str().map(ToOwned::to_owned))
.collect()
}
fn build_minimal_body(fields: &[String]) -> Bytes {
let obj: serde_json::Map<String, Value> = fields
.iter()
.map(|f| (f.clone(), Value::String(String::new())))
.collect();
let json = Value::Object(obj);
Bytes::from(serde_json::to_vec(&json).unwrap_or_default())
}
#[cfg(test)]
#[path = "problem_details_tests.rs"]
mod tests;