1#![deny(clippy::all)]
8#![warn(clippy::pedantic)]
9#![deny(missing_docs)]
10
11mod serde_helpers;
12
13use bytes::Bytes;
14use http::{HeaderMap, Method, StatusCode};
15use serde::{Deserialize, Serialize};
16use serde_helpers::{
17 bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
18};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ResponseSurface {
26 #[serde(with = "status_code_serde")]
28 pub status: StatusCode,
29 #[serde(with = "header_map_serde")]
31 pub headers: HeaderMap,
32 #[serde(with = "bytes_serde")]
34 pub body: Bytes,
35 pub timing_ns: u64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ProbeDefinition {
47 pub url: String,
49 #[serde(with = "method_serde")]
51 pub method: Method,
52 #[serde(with = "header_map_serde")]
54 pub headers: HeaderMap,
55 #[serde(with = "opt_bytes_serde")]
57 pub body: Option<Bytes>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ProbeSet {
67 pub baseline: Vec<ResponseSurface>,
69 pub probe: Vec<ResponseSurface>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[non_exhaustive]
78pub enum OracleClass {
79 Existence,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub enum OracleVerdict {
86 Confirmed,
89 Likely,
92 Inconclusive,
94 NotPresent,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub enum Severity {
103 High,
106 Medium,
108 Low,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct DiffedHeader {
118 pub name: String,
120 #[serde(skip_serializing_if = "Option::is_none", default)]
122 pub baseline: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none", default)]
125 pub probe: Option<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ResponseSummary {
133 pub status: u16,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct OracleResult {
140 pub class: OracleClass,
142 pub verdict: OracleVerdict,
144 pub evidence: Vec<String>,
147 pub severity: Option<Severity>,
149 #[serde(skip_serializing_if = "Option::is_none", default)]
152 pub label: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none", default)]
157 pub leaks: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none", default)]
161 pub rfc_basis: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none", default)]
164 pub baseline_summary: Option<ResponseSummary>,
165 #[serde(skip_serializing_if = "Option::is_none", default)]
167 pub probe_summary: Option<ResponseSummary>,
168 #[serde(skip_serializing_if = "Vec::is_empty", default)]
171 pub header_diffs: Vec<DiffedHeader>,
172}
173
174#[derive(Debug, thiserror::Error)]
176#[non_exhaustive]
177pub enum Error {
178 #[error("http error: {0}")]
180 Http(String),
181 #[error("analysis error: {0}")]
183 Analysis(String),
184 #[error("serialization error: {0}")]
186 Serialization(#[from] serde_json::Error),
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn confirmed_result_with_metadata() -> OracleResult {
194 OracleResult {
195 class: OracleClass::Existence,
196 verdict: OracleVerdict::Confirmed,
197 evidence: vec!["403 (baseline) vs 404 (probe)".into()],
198 severity: Some(Severity::High),
199 label: Some("Authorization-based differential".into()),
200 leaks: Some("Resource existence confirmed to low-privilege callers".into()),
201 rfc_basis: Some("RFC 9110 §15.5.4".into()),
202 baseline_summary: None,
203 probe_summary: None,
204 header_diffs: vec![],
205 }
206 }
207
208 fn not_present_result() -> OracleResult {
209 OracleResult {
210 class: OracleClass::Existence,
211 verdict: OracleVerdict::NotPresent,
212 evidence: vec!["404 (baseline) vs 404 (probe)".into()],
213 severity: None,
214 label: None,
215 leaks: None,
216 rfc_basis: None,
217 baseline_summary: None,
218 probe_summary: None,
219 header_diffs: vec![],
220 }
221 }
222
223 #[test]
224 fn serialize_confirmed_includes_metadata_fields() {
225 let result = confirmed_result_with_metadata();
226 let json = serde_json::to_value(&result).expect("serialization failed");
227 assert_eq!(json["label"], "Authorization-based differential");
228 assert_eq!(json["leaks"], "Resource existence confirmed to low-privilege callers");
229 assert_eq!(json["rfc_basis"], "RFC 9110 §15.5.4");
230 }
231
232 #[test]
233 fn serialize_not_present_omits_none_metadata() {
234 let result = not_present_result();
235 let json = serde_json::to_value(&result).expect("serialization failed");
236 assert!(!json.as_object().expect("expected object").contains_key("label"));
237 assert!(!json.as_object().expect("expected object").contains_key("leaks"));
238 assert!(!json.as_object().expect("expected object").contains_key("rfc_basis"));
239 }
240
241 #[test]
242 fn roundtrip_confirmed_preserves_metadata() {
243 let original = confirmed_result_with_metadata();
244 let json = serde_json::to_string(&original).expect("serialization failed");
245 let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
246 assert_eq!(deserialized.label, original.label);
247 assert_eq!(deserialized.leaks, original.leaks);
248 assert_eq!(deserialized.rfc_basis, original.rfc_basis);
249 }
250
251 #[test]
252 fn roundtrip_not_present_preserves_none_metadata() {
253 let original = not_present_result();
254 let json = serde_json::to_string(&original).expect("serialization failed");
255 let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
256 assert_eq!(deserialized.label, None);
257 assert_eq!(deserialized.leaks, None);
258 assert_eq!(deserialized.rfc_basis, None);
259 }
260
261 #[test]
262 fn deserialize_legacy_json_without_metadata_defaults_to_none() {
263 let legacy = r#"{
264 "class": "Existence",
265 "verdict": "Confirmed",
266 "evidence": ["403 (baseline) vs 404 (probe)"],
267 "severity": "High"
268 }"#;
269 let result: OracleResult = serde_json::from_str(legacy).expect("deserialization failed");
270 assert_eq!(result.label, None);
271 assert_eq!(result.leaks, None);
272 assert_eq!(result.rfc_basis, None);
273 }
274
275 #[test]
276 fn diffed_header_serializes_both_values() {
277 let dh = DiffedHeader {
278 name: "x-rate-limit-remaining".into(),
279 baseline: Some("100".into()),
280 probe: Some("0".into()),
281 };
282 let json = serde_json::to_value(&dh).expect("serialization failed");
283 assert_eq!(json["name"], "x-rate-limit-remaining");
284 assert_eq!(json["baseline"], "100");
285 assert_eq!(json["probe"], "0");
286 }
287
288 #[test]
289 fn diffed_header_serializes_baseline_only() {
290 let dh = DiffedHeader {
291 name: "x-powered-by".into(),
292 baseline: Some("Express".into()),
293 probe: None,
294 };
295 let json = serde_json::to_value(&dh).expect("serialization failed");
296 assert_eq!(json["baseline"], "Express");
297 assert!(!json.as_object().expect("expected object").contains_key("probe"));
298 }
299
300 #[test]
301 fn diffed_header_serializes_probe_only() {
302 let dh = DiffedHeader {
303 name: "x-powered-by".into(),
304 baseline: None,
305 probe: Some("nginx".into()),
306 };
307 let json = serde_json::to_value(&dh).expect("serialization failed");
308 assert_eq!(json["probe"], "nginx");
309 assert!(!json.as_object().expect("expected object").contains_key("baseline"));
310 }
311
312 #[test]
313 fn response_summary_serializes_status() {
314 let rs = ResponseSummary { status: 403 };
315 let json = serde_json::to_string(&rs).expect("serialization failed");
316 let back: ResponseSummary = serde_json::from_str(&json).expect("deserialization failed");
317 assert_eq!(back.status, 403);
318 }
319
320 #[test]
321 fn oracle_result_with_summaries_serializes() {
322 let result = OracleResult {
323 class: OracleClass::Existence,
324 verdict: OracleVerdict::Confirmed,
325 evidence: vec!["403 (baseline) vs 404 (probe)".into()],
326 severity: Some(Severity::High),
327 label: None,
328 leaks: None,
329 rfc_basis: None,
330 baseline_summary: Some(ResponseSummary { status: 403 }),
331 probe_summary: Some(ResponseSummary { status: 404 }),
332 header_diffs: vec![DiffedHeader {
333 name: "content-length".into(),
334 baseline: Some("512".into()),
335 probe: Some("128".into()),
336 }],
337 };
338 let json = serde_json::to_value(&result).expect("serialization failed");
339 assert_eq!(json["baseline_summary"]["status"], 403);
340 assert_eq!(json["probe_summary"]["status"], 404);
341 assert_eq!(json["header_diffs"][0]["name"], "content-length");
342 }
343
344 #[test]
345 fn oracle_result_empty_header_diffs_omitted() {
346 let result = OracleResult {
347 class: OracleClass::Existence,
348 verdict: OracleVerdict::NotPresent,
349 evidence: vec![],
350 severity: None,
351 label: None,
352 leaks: None,
353 rfc_basis: None,
354 baseline_summary: None,
355 probe_summary: None,
356 header_diffs: vec![],
357 };
358 let json = serde_json::to_value(&result).expect("serialization failed");
359 assert!(!json.as_object().expect("expected object").contains_key("header_diffs"));
360 }
361
362 #[test]
363 fn legacy_deserialization_without_summaries_defaults_to_none() {
364 let legacy = r#"{
365 "class": "Existence",
366 "verdict": "Confirmed",
367 "evidence": ["403 (baseline) vs 404 (probe)"],
368 "severity": "High"
369 }"#;
370 let result: OracleResult = serde_json::from_str(legacy).expect("deserialization failed");
371 assert_eq!(result.baseline_summary, None);
372 assert_eq!(result.probe_summary, None);
373 assert!(result.header_diffs.is_empty());
374 }
375}