#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]
mod serde_helpers;
use bytes::Bytes;
use http::{HeaderMap, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_helpers::{
bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseSurface {
#[serde(with = "status_code_serde")]
pub status: StatusCode,
#[serde(with = "header_map_serde")]
pub headers: HeaderMap,
#[serde(with = "bytes_serde")]
pub body: Bytes,
pub timing_ns: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeDefinition {
pub url: String,
#[serde(with = "method_serde")]
pub method: Method,
#[serde(with = "header_map_serde")]
pub headers: HeaderMap,
#[serde(with = "opt_bytes_serde")]
pub body: Option<Bytes>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeSet {
pub baseline: Vec<ResponseSurface>,
pub probe: Vec<ResponseSurface>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum OracleClass {
Existence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum OracleVerdict {
Confirmed,
Likely,
Inconclusive,
NotPresent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Severity {
High,
Medium,
Low,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffedHeader {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub baseline: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub probe: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResponseSummary {
pub status: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleResult {
pub class: OracleClass,
pub verdict: OracleVerdict,
pub evidence: Vec<String>,
pub severity: Option<Severity>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub leaks: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub rfc_basis: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub baseline_summary: Option<ResponseSummary>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub probe_summary: Option<ResponseSummary>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub header_diffs: Vec<DiffedHeader>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("http error: {0}")]
Http(String),
#[error("analysis error: {0}")]
Analysis(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
fn confirmed_result_with_metadata() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
evidence: vec!["403 (baseline) vs 404 (probe)".into()],
severity: Some(Severity::High),
label: Some("Authorization-based differential".into()),
leaks: Some("Resource existence confirmed to low-privilege callers".into()),
rfc_basis: Some("RFC 9110 §15.5.4".into()),
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
}
}
fn not_present_result() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
evidence: vec!["404 (baseline) vs 404 (probe)".into()],
severity: None,
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
}
}
#[test]
fn serialize_confirmed_includes_metadata_fields() {
let result = confirmed_result_with_metadata();
let json = serde_json::to_value(&result).expect("serialization failed");
assert_eq!(json["label"], "Authorization-based differential");
assert_eq!(json["leaks"], "Resource existence confirmed to low-privilege callers");
assert_eq!(json["rfc_basis"], "RFC 9110 §15.5.4");
}
#[test]
fn serialize_not_present_omits_none_metadata() {
let result = not_present_result();
let json = serde_json::to_value(&result).expect("serialization failed");
assert!(!json.as_object().expect("expected object").contains_key("label"));
assert!(!json.as_object().expect("expected object").contains_key("leaks"));
assert!(!json.as_object().expect("expected object").contains_key("rfc_basis"));
}
#[test]
fn roundtrip_confirmed_preserves_metadata() {
let original = confirmed_result_with_metadata();
let json = serde_json::to_string(&original).expect("serialization failed");
let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(deserialized.label, original.label);
assert_eq!(deserialized.leaks, original.leaks);
assert_eq!(deserialized.rfc_basis, original.rfc_basis);
}
#[test]
fn roundtrip_not_present_preserves_none_metadata() {
let original = not_present_result();
let json = serde_json::to_string(&original).expect("serialization failed");
let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(deserialized.label, None);
assert_eq!(deserialized.leaks, None);
assert_eq!(deserialized.rfc_basis, None);
}
#[test]
fn deserialize_legacy_json_without_metadata_defaults_to_none() {
let legacy = r#"{
"class": "Existence",
"verdict": "Confirmed",
"evidence": ["403 (baseline) vs 404 (probe)"],
"severity": "High"
}"#;
let result: OracleResult = serde_json::from_str(legacy).expect("deserialization failed");
assert_eq!(result.label, None);
assert_eq!(result.leaks, None);
assert_eq!(result.rfc_basis, None);
}
#[test]
fn diffed_header_serializes_both_values() {
let dh = DiffedHeader {
name: "x-rate-limit-remaining".into(),
baseline: Some("100".into()),
probe: Some("0".into()),
};
let json = serde_json::to_value(&dh).expect("serialization failed");
assert_eq!(json["name"], "x-rate-limit-remaining");
assert_eq!(json["baseline"], "100");
assert_eq!(json["probe"], "0");
}
#[test]
fn diffed_header_serializes_baseline_only() {
let dh = DiffedHeader {
name: "x-powered-by".into(),
baseline: Some("Express".into()),
probe: None,
};
let json = serde_json::to_value(&dh).expect("serialization failed");
assert_eq!(json["baseline"], "Express");
assert!(!json.as_object().expect("expected object").contains_key("probe"));
}
#[test]
fn diffed_header_serializes_probe_only() {
let dh = DiffedHeader {
name: "x-powered-by".into(),
baseline: None,
probe: Some("nginx".into()),
};
let json = serde_json::to_value(&dh).expect("serialization failed");
assert_eq!(json["probe"], "nginx");
assert!(!json.as_object().expect("expected object").contains_key("baseline"));
}
#[test]
fn response_summary_serializes_status() {
let rs = ResponseSummary { status: 403 };
let json = serde_json::to_string(&rs).expect("serialization failed");
let back: ResponseSummary = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(back.status, 403);
}
#[test]
fn oracle_result_with_summaries_serializes() {
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
evidence: vec!["403 (baseline) vs 404 (probe)".into()],
severity: Some(Severity::High),
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: Some(ResponseSummary { status: 403 }),
probe_summary: Some(ResponseSummary { status: 404 }),
header_diffs: vec![DiffedHeader {
name: "content-length".into(),
baseline: Some("512".into()),
probe: Some("128".into()),
}],
};
let json = serde_json::to_value(&result).expect("serialization failed");
assert_eq!(json["baseline_summary"]["status"], 403);
assert_eq!(json["probe_summary"]["status"], 404);
assert_eq!(json["header_diffs"][0]["name"], "content-length");
}
#[test]
fn oracle_result_empty_header_diffs_omitted() {
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
evidence: vec![],
severity: None,
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
};
let json = serde_json::to_value(&result).expect("serialization failed");
assert!(!json.as_object().expect("expected object").contains_key("header_diffs"));
}
#[test]
fn legacy_deserialization_without_summaries_defaults_to_none() {
let legacy = r#"{
"class": "Existence",
"verdict": "Confirmed",
"evidence": ["403 (baseline) vs 404 (probe)"],
"severity": "High"
}"#;
let result: OracleResult = serde_json::from_str(legacy).expect("deserialization failed");
assert_eq!(result.baseline_summary, None);
assert_eq!(result.probe_summary, None);
assert!(result.header_diffs.is_empty());
}
}