Skip to main content

parlov_core/
lib.rs

1//! Shared types, error types, and oracle class definitions used across all parlov crates.
2//!
3//! This crate is the dependency root of the workspace — it carries no deps on other workspace
4//! crates and is designed to compile fast. Everything in here is pure data: no I/O, no async,
5//! no heavy dependencies.
6
7#![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/// A single HTTP interaction: full response surface and wall-clock timing.
21///
22/// Captures everything needed for differential analysis — status, headers, body, and timing —
23/// in one flat structure.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ResponseSurface {
26    /// HTTP status code returned by the server.
27    #[serde(with = "status_code_serde")]
28    pub status: StatusCode,
29    /// Full response header map.
30    #[serde(with = "header_map_serde")]
31    pub headers: HeaderMap,
32    /// Raw response body bytes, serialized as a base64-encoded byte sequence.
33    #[serde(with = "bytes_serde")]
34    pub body: Bytes,
35    /// Wall-clock response time in nanoseconds, measured from first byte sent to last byte
36    /// received.
37    pub timing_ns: u64,
38}
39
40/// A single HTTP request to execute against a target.
41///
42/// The authorization context is expressed entirely through the `headers` field — set an
43/// `Authorization` header for bearer tokens, API keys, or Basic auth. No special-case auth
44/// fields.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ProbeDefinition {
47    /// Fully-qualified target URL including scheme, host, path, and any query parameters.
48    pub url: String,
49    /// HTTP method for the request.
50    #[serde(with = "method_serde")]
51    pub method: Method,
52    /// Request headers, including any authorization context.
53    #[serde(with = "header_map_serde")]
54    pub headers: HeaderMap,
55    /// Request body. `None` for GET, HEAD, DELETE; `Some` for POST, PATCH, PUT.
56    #[serde(with = "opt_bytes_serde")]
57    pub body: Option<Bytes>,
58}
59
60/// Paired response surfaces for differential analysis.
61///
62/// `baseline` holds responses for the control input (e.g. a known-existing resource ID).
63/// `probe` holds responses for the variable input (e.g. a randomly generated nonexistent ID).
64/// Multiple samples per side support statistical analysis for timing oracles.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ProbeSet {
67    /// Responses for the known-valid / control input.
68    pub baseline: Vec<ResponseSurface>,
69    /// Responses for the unknown / suspect input.
70    pub probe: Vec<ResponseSurface>,
71}
72
73/// The oracle class being probed.
74///
75/// Each variant corresponds to a distinct detection strategy and analysis pipeline.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[non_exhaustive]
78pub enum OracleClass {
79    /// Status-code or body differential between an existing and nonexistent resource.
80    Existence,
81}
82
83/// Confidence level of an oracle detection result.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub enum OracleVerdict {
86    /// Signal is unambiguous: differential is consistent, statistically significant, and matches
87    /// a known oracle pattern.
88    Confirmed,
89    /// Signal is present and consistent with an oracle, but evidence is not conclusive (e.g.
90    /// borderline p-value, single sample).
91    Likely,
92    /// Signal is present but too weak or inconsistent to classify.
93    Inconclusive,
94    /// No differential signal detected; the endpoint does not exhibit this oracle.
95    NotPresent,
96}
97
98/// Severity of a confirmed or likely oracle.
99///
100/// `None` on an `OracleResult` when the verdict is `NotPresent` or `Inconclusive`.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub enum Severity {
103    /// Directly actionable: resource existence, valid credentials, or session state is leaked
104    /// to unauthenticated or low-privilege callers.
105    High,
106    /// Leaks internal state but requires additional steps to exploit.
107    Medium,
108    /// Informational: leaks metadata that may assist further enumeration.
109    Low,
110}
111
112/// Machine-readable record of a single header that differed between baseline and probe sides.
113///
114/// `baseline` and `probe` are `None` when the header was absent on that side, `Some` when present.
115/// A header absent on one side and present on the other is itself a differential signal.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct DiffedHeader {
118    /// Lowercase header name, e.g. `"x-rate-limit-remaining"`.
119    pub name: String,
120    /// Header value on the baseline side; `None` if the header was absent.
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub baseline: Option<String>,
123    /// Header value on the probe side; `None` if the header was absent.
124    #[serde(skip_serializing_if = "Option::is_none", default)]
125    pub probe: Option<String>,
126}
127
128/// Per-side response summary for machine-readable oracle output.
129///
130/// Intentionally minimal — carries only `status` now. Will grow when body diffing lands.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ResponseSummary {
133    /// HTTP status code as a `u16`.
134    pub status: u16,
135}
136
137/// The result of running an oracle analyzer against a `ProbeSet`.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct OracleResult {
140    /// Which oracle class produced this result.
141    pub class: OracleClass,
142    /// Confidence verdict.
143    pub verdict: OracleVerdict,
144    /// Human-readable descriptions of each signal contributing to the verdict, e.g.
145    /// `"403 (baseline) vs 404 (probe)"`.
146    pub evidence: Vec<String>,
147    /// Severity when the verdict is `Confirmed` or `Likely`; `None` when `NotPresent`.
148    pub severity: Option<Severity>,
149    /// Human-readable name for the detected pattern, e.g. `"Authorization-based differential"`.
150    /// `None` when no oracle is detected.
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub label: Option<String>,
153    /// What information the oracle leaks, e.g.
154    /// `"Resource existence confirmed to low-privilege callers"`.
155    /// `None` when no oracle is detected.
156    #[serde(skip_serializing_if = "Option::is_none", default)]
157    pub leaks: Option<String>,
158    /// RFC section grounding the behavior, e.g. `"RFC 9110 §15.5.4"`.
159    /// `None` when no oracle is detected.
160    #[serde(skip_serializing_if = "Option::is_none", default)]
161    pub rfc_basis: Option<String>,
162    /// Response summary for the baseline side; `None` when not populated by the analyzer.
163    #[serde(skip_serializing_if = "Option::is_none", default)]
164    pub baseline_summary: Option<ResponseSummary>,
165    /// Response summary for the probe side; `None` when not populated by the analyzer.
166    #[serde(skip_serializing_if = "Option::is_none", default)]
167    pub probe_summary: Option<ResponseSummary>,
168    /// Headers that differed between baseline and probe — the cross-cutting differential signal.
169    /// Empty when no header differential was detected or the analyzer did not populate it.
170    #[serde(skip_serializing_if = "Vec::is_empty", default)]
171    pub header_diffs: Vec<DiffedHeader>,
172}
173
174/// Errors produced by parlov crates.
175#[derive(Debug, thiserror::Error)]
176#[non_exhaustive]
177pub enum Error {
178    /// HTTP-level error from the probe engine.
179    #[error("http error: {0}")]
180    Http(String),
181    /// Analysis failed due to insufficient or malformed probe data.
182    #[error("analysis error: {0}")]
183    Analysis(String),
184    /// Serialization or deserialization failure.
185    #[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}