use std::sync::{Mutex, OnceLock};
use axum::body::{Body, Bytes};
use axum::http::{Request, Response, StatusCode};
use axum::middleware::Next;
const ALLOWED: &[&str] = &[];
pub const ALLOWED_COUNT: usize = 0;
static VIOLATIONS: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
fn violations() -> &'static Mutex<Vec<String>> {
VIOLATIONS.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn observed_violations() -> Vec<String> {
violations().lock().expect("violations lock").clone()
}
pub fn clear_violations() {
violations().lock().expect("violations lock").clear();
}
pub async fn validate_response(req: Request<Body>, next: Next) -> Response<Body> {
let task = req
.headers()
.get("trust-task")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let res = next.run(req).await;
let Some(task) = task else { return res };
if !res.status().is_success() {
return res;
}
let (parts, body) = res.into_parts();
let bytes = match axum::body::to_bytes(body, usize::MAX).await {
Ok(b) => b,
Err(_) => return Response::from_parts(parts, Body::empty()),
};
match check(&task, parts.status, &bytes) {
None => Response::from_parts(parts, Body::from(bytes)),
Some(msg) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"error": "responseSchemaViolation",
"message": msg,
})
.to_string(),
))
.expect("static response builds"),
}
}
fn check(task: &str, status: StatusCode, bytes: &Bytes) -> Option<String> {
if status == StatusCode::NO_CONTENT || bytes.is_empty() {
return None;
}
let schema = trust_tasks_rs::schema_index::schema_for(&format!("{task}#response"))?;
let value = serde_json::from_slice::<serde_json::Value>(bytes).ok()?;
let payload = value.get("payload").unwrap_or(&value);
let Err(e) = trust_tasks_rs::validate::against_schema(schema, payload) else {
return None;
};
let msg = format!("{task}: {e}");
eprintln!("RESPONSE-CONFORMANCE VIOLATION {msg}");
violations()
.lock()
.expect("violations lock")
.push(msg.clone());
if ALLOWED.contains(&task) {
return None;
}
Some(msg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_allowlist_only_shrinks() {
assert_eq!(
ALLOWED.len(),
ALLOWED_COUNT,
"the response-conformance allowlist changed. Removing a task? \
Lower ALLOWED_COUNT with it. Adding one? That needs a reason in \
review, not a quiet edit — a route whose response does not match \
its published schema is a defect, and this list exists to record \
the ones already known, not to absorb new ones."
);
}
#[test]
fn a_violation_is_recorded_as_well_as_raised() {
clear_violations();
let out = check(
"https://trusttasks.org/spec/auth/whoami/0.1",
StatusCode::OK,
&Bytes::from_static(b"{\"nope\":1}"),
);
assert!(
out.is_some(),
"a non-allowlisted violation must fail the build"
);
assert_eq!(
observed_violations().len(),
1,
"it must also be recorded, or a run cannot be swept for all of them"
);
}
#[test]
fn an_unknown_task_is_not_a_violation() {
assert!(
check(
"https://example.invalid/not/a/task/9.9",
StatusCode::OK,
&Bytes::from_static(b"{}"),
)
.is_none()
);
}
}