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, Serialize, Deserialize)]
114pub struct OracleResult {
115 pub class: OracleClass,
117 pub verdict: OracleVerdict,
119 pub evidence: Vec<String>,
122 pub severity: Option<Severity>,
124 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub label: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none", default)]
132 pub leaks: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none", default)]
136 pub rfc_basis: Option<String>,
137}
138
139#[derive(Debug, thiserror::Error)]
141#[non_exhaustive]
142pub enum Error {
143 #[error("http error: {0}")]
145 Http(String),
146 #[error("analysis error: {0}")]
148 Analysis(String),
149 #[error("serialization error: {0}")]
151 Serialization(#[from] serde_json::Error),
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn confirmed_result_with_metadata() -> OracleResult {
159 OracleResult {
160 class: OracleClass::Existence,
161 verdict: OracleVerdict::Confirmed,
162 evidence: vec!["403 (baseline) vs 404 (probe)".into()],
163 severity: Some(Severity::High),
164 label: Some("Authorization-based differential".into()),
165 leaks: Some("Resource existence confirmed to low-privilege callers".into()),
166 rfc_basis: Some("RFC 9110 §15.5.4".into()),
167 }
168 }
169
170 fn not_present_result() -> OracleResult {
171 OracleResult {
172 class: OracleClass::Existence,
173 verdict: OracleVerdict::NotPresent,
174 evidence: vec!["404 (baseline) vs 404 (probe)".into()],
175 severity: None,
176 label: None,
177 leaks: None,
178 rfc_basis: None,
179 }
180 }
181
182 #[test]
183 fn serialize_confirmed_includes_metadata_fields() {
184 let result = confirmed_result_with_metadata();
185 let json = serde_json::to_value(&result).expect("serialization failed");
186 assert_eq!(json["label"], "Authorization-based differential");
187 assert_eq!(json["leaks"], "Resource existence confirmed to low-privilege callers");
188 assert_eq!(json["rfc_basis"], "RFC 9110 §15.5.4");
189 }
190
191 #[test]
192 fn serialize_not_present_omits_none_metadata() {
193 let result = not_present_result();
194 let json = serde_json::to_value(&result).expect("serialization failed");
195 assert!(!json.as_object().expect("expected object").contains_key("label"));
196 assert!(!json.as_object().expect("expected object").contains_key("leaks"));
197 assert!(!json.as_object().expect("expected object").contains_key("rfc_basis"));
198 }
199
200 #[test]
201 fn roundtrip_confirmed_preserves_metadata() {
202 let original = confirmed_result_with_metadata();
203 let json = serde_json::to_string(&original).expect("serialization failed");
204 let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
205 assert_eq!(deserialized.label, original.label);
206 assert_eq!(deserialized.leaks, original.leaks);
207 assert_eq!(deserialized.rfc_basis, original.rfc_basis);
208 }
209
210 #[test]
211 fn roundtrip_not_present_preserves_none_metadata() {
212 let original = not_present_result();
213 let json = serde_json::to_string(&original).expect("serialization failed");
214 let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
215 assert_eq!(deserialized.label, None);
216 assert_eq!(deserialized.leaks, None);
217 assert_eq!(deserialized.rfc_basis, None);
218 }
219
220 #[test]
221 fn deserialize_legacy_json_without_metadata_defaults_to_none() {
222 let legacy = r#"{
223 "class": "Existence",
224 "verdict": "Confirmed",
225 "evidence": ["403 (baseline) vs 404 (probe)"],
226 "severity": "High"
227 }"#;
228 let result: OracleResult = serde_json::from_str(legacy).expect("deserialization failed");
229 assert_eq!(result.label, None);
230 assert_eq!(result.leaks, None);
231 assert_eq!(result.rfc_basis, None);
232 }
233}