#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]
mod exchange;
mod finding_id;
mod scoring;
mod serde_helpers;
mod signal;
mod technique;
pub use exchange::{DifferentialSet, ProbeExchange};
pub use finding_id::finding_id;
pub use scoring::{ScoringDimension, ScoringReason};
pub use signal::{ImpactClass, Signal, SignalKind};
pub use technique::{NormativeStrength, Technique, Vector};
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>,
}
#[deprecated(since = "0.4.0", note = "use DifferentialSet instead — see system-design.md")]
#[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, Serialize, Deserialize)]
pub struct OracleResult {
pub class: OracleClass,
pub verdict: OracleVerdict,
pub severity: Option<Severity>,
#[serde(default)]
pub confidence: u8,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub impact_class: Option<ImpactClass>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub reasons: Vec<ScoringReason>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub signals: Vec<Signal>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub technique_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub vector: Option<Vector>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub normative_strength: Option<NormativeStrength>,
#[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>,
}
impl OracleResult {
#[must_use]
pub fn primary_evidence(&self) -> &str {
self.signals
.iter()
.find(|s| s.kind == SignalKind::StatusCodeDiff)
.or_else(|| self.signals.first())
.map_or("—", |s| s.evidence.as_str())
}
}
#[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,
severity: Some(Severity::High),
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence: "403 (baseline) vs 404 (probe)".into(),
rfc_basis: None,
}],
technique_id: None,
vector: None,
normative_strength: None,
label: Some("Authorization-based differential".into()),
leaks: Some("Resource existence confirmed to low-privilege callers".into()),
rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
}
}
fn not_present_result() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
severity: None,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence: "404 (baseline) vs 404 (probe)".into(),
rfc_basis: None,
}],
technique_id: None,
vector: None,
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
}
}
#[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 \u{00a7}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_minimal_json_defaults_to_none() {
let minimal = r#"{
"class": "Existence",
"verdict": "Confirmed",
"severity": "High"
}"#;
let result: OracleResult =
serde_json::from_str(minimal).expect("deserialization failed");
assert_eq!(result.label, None);
assert_eq!(result.leaks, None);
assert_eq!(result.rfc_basis, None);
assert!(result.signals.is_empty());
assert_eq!(result.technique_id, None);
assert_eq!(result.confidence, 0);
assert_eq!(result.impact_class, None);
assert!(result.reasons.is_empty());
}
#[test]
fn oracle_result_with_technique_context_serializes() {
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: Some(Severity::High),
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence: "304 vs 404".into(),
rfc_basis: Some("RFC 9110 \u{00a7}13.1.2".into()),
}],
technique_id: Some("if-none-match".into()),
vector: Some(Vector::CacheProbing),
normative_strength: Some(NormativeStrength::Must),
label: None,
leaks: None,
rfc_basis: None,
};
let json = serde_json::to_value(&result).expect("serialization failed");
assert_eq!(json["technique_id"], "if-none-match");
assert_eq!(json["vector"], "CacheProbing");
assert_eq!(json["normative_strength"], "Must");
assert_eq!(json["signals"][0]["kind"], "StatusCodeDiff");
assert_eq!(json["signals"][0]["evidence"], "304 vs 404");
assert_eq!(json["signals"][0]["rfc_basis"], "RFC 9110 \u{00a7}13.1.2");
}
#[test]
fn oracle_result_roundtrip_with_technique_context() {
let original = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Likely,
severity: Some(Severity::Medium),
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::HeaderPresence,
evidence: "ETag present in baseline, absent in probe".into(),
rfc_basis: None,
}],
technique_id: Some("get-200-404".into()),
vector: Some(Vector::StatusCodeDiff),
normative_strength: Some(NormativeStrength::Should),
label: Some("Status code differential".into()),
leaks: Some("Resource existence".into()),
rfc_basis: Some("RFC 9110 \u{00a7}15.5.5".into()),
};
let json = serde_json::to_string(&original).expect("serialization failed");
let back: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(back.technique_id, original.technique_id);
assert_eq!(back.vector, original.vector);
assert_eq!(back.normative_strength, original.normative_strength);
assert_eq!(back.signals.len(), 1);
assert_eq!(back.signals[0].kind, SignalKind::HeaderPresence);
}
#[test]
fn primary_evidence_returns_status_code_diff() {
let result = confirmed_result_with_metadata();
assert_eq!(result.primary_evidence(), "403 (baseline) vs 404 (probe)");
}
#[test]
fn primary_evidence_falls_back_to_first_signal() {
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: Some(Severity::Medium),
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::HeaderPresence,
evidence: "etag present in baseline".into(),
rfc_basis: None,
}],
technique_id: None,
vector: None,
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
};
assert_eq!(result.primary_evidence(), "etag present in baseline");
}
#[test]
fn primary_evidence_returns_dash_when_empty() {
let result = not_present_result();
let mut empty = result;
empty.signals.clear();
assert_eq!(empty.primary_evidence(), "\u{2014}");
}
#[test]
fn signal_kind_copy_and_eq() {
let a = SignalKind::StatusCodeDiff;
let b = a;
assert_eq!(a, b);
}
#[test]
fn vector_copy_and_eq() {
let a = Vector::CacheProbing;
let b = a;
assert_eq!(a, b);
}
#[test]
fn normative_strength_copy_and_eq() {
let a = NormativeStrength::Must;
let b = a;
assert_eq!(a, b);
assert_ne!(a, NormativeStrength::May);
}
#[test]
fn technique_clone() {
let t = Technique {
id: "test",
name: "Test technique",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Must,
};
let t2 = t.clone();
assert_eq!(t2.id, "test");
assert_eq!(t2.vector, Vector::StatusCodeDiff);
}
#[test]
fn probe_exchange_pairs_request_and_response() {
let exchange = ProbeExchange {
request: ProbeDefinition {
url: "https://example.com/resource/1".into(),
method: http::Method::GET,
headers: HeaderMap::new(),
body: None,
},
response: ResponseSurface {
status: http::StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::new(),
timing_ns: 1_000_000,
},
};
assert_eq!(exchange.request.url, "https://example.com/resource/1");
assert_eq!(exchange.response.status, http::StatusCode::OK);
}
#[test]
fn differential_set_carries_technique() {
let technique = Technique {
id: "get-200-404",
name: "GET 200/404",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Must,
};
let ds = DifferentialSet {
baseline: vec![],
probe: vec![],
technique,
};
assert_eq!(ds.technique.id, "get-200-404");
assert_eq!(ds.technique.strength, NormativeStrength::Must);
}
#[test]
fn technique_without_target_signals_constructs() {
let t = Technique {
id: "range-416",
name: "Range 416/404",
oracle_class: OracleClass::Existence,
vector: Vector::CacheProbing,
strength: NormativeStrength::Should,
};
let t2 = t.clone();
assert_eq!(t2.id, "range-416");
assert_eq!(t2.strength, NormativeStrength::Should);
assert_eq!(t2.oracle_class, OracleClass::Existence);
}
#[test]
fn impact_class_copy_and_eq() {
let a = ImpactClass::High;
let b = a;
assert_eq!(a, b);
assert_ne!(a, ImpactClass::Low);
}
#[test]
fn impact_class_serialize_roundtrip() {
let json = serde_json::to_string(&ImpactClass::Medium).expect("serialization failed");
let back: ImpactClass = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(back, ImpactClass::Medium);
}
#[test]
fn scoring_dimension_copy_and_eq() {
let a = ScoringDimension::Confidence;
let b = a;
assert_eq!(a, b);
assert_ne!(a, ScoringDimension::Impact);
}
#[test]
fn scoring_reason_serialize_roundtrip() {
let reason = ScoringReason {
description: "Status differential 416 vs 404".into(),
points: 75,
dimension: ScoringDimension::Confidence,
};
let json = serde_json::to_string(&reason).expect("serialization failed");
let back: ScoringReason = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(back.description, "Status differential 416 vs 404");
assert_eq!(back.points, 75);
assert_eq!(back.dimension, ScoringDimension::Confidence);
}
#[test]
fn scoring_reason_negative_points() {
let reason = ScoringReason {
description: "Inconsistent across samples".into(),
points: -10,
dimension: ScoringDimension::Confidence,
};
let json = serde_json::to_string(&reason).expect("serialization failed");
let back: ScoringReason = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(back.points, -10);
}
#[test]
fn oracle_result_with_confidence_and_impact_serializes() {
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: Some(Severity::High),
confidence: 88,
impact_class: Some(ImpactClass::High),
reasons: vec![
ScoringReason {
description: "Status differential 416 vs 404".into(),
points: 75,
dimension: ScoringDimension::Confidence,
},
ScoringReason {
description: "Content-Range reveals exact size".into(),
points: 12,
dimension: ScoringDimension::Impact,
},
],
signals: vec![],
technique_id: None,
vector: None,
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
};
let json = serde_json::to_value(&result).expect("serialization failed");
assert_eq!(json["confidence"], 88);
assert_eq!(json["impact_class"], "High");
assert_eq!(json["reasons"].as_array().expect("expected array").len(), 2);
assert_eq!(json["reasons"][0]["points"], 75);
assert_eq!(json["reasons"][1]["dimension"], "Impact");
}
#[test]
fn oracle_result_zero_confidence_omits_impact_and_reasons() {
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
severity: None,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![],
technique_id: None,
vector: None,
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
};
let json = serde_json::to_value(&result).expect("serialization failed");
let obj = json.as_object().expect("expected object");
assert!(!obj.contains_key("impact_class"));
assert!(!obj.contains_key("reasons"));
}
}