parlov-core 0.3.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
//! Shared types, error types, and oracle class definitions used across all parlov crates.
//!
//! This crate is the dependency root of the workspace — it carries no deps on other workspace
//! crates and is designed to compile fast. Everything in here is pure data: no I/O, no async,
//! no heavy dependencies.

#![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,
};

/// A single HTTP interaction: full response surface and wall-clock timing.
///
/// Captures everything needed for differential analysis — status, headers, body, and timing —
/// in one flat structure.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseSurface {
    /// HTTP status code returned by the server.
    #[serde(with = "status_code_serde")]
    pub status: StatusCode,
    /// Full response header map.
    #[serde(with = "header_map_serde")]
    pub headers: HeaderMap,
    /// Raw response body bytes, serialized as a base64-encoded byte sequence.
    #[serde(with = "bytes_serde")]
    pub body: Bytes,
    /// Wall-clock response time in nanoseconds, measured from first byte sent to last byte
    /// received.
    pub timing_ns: u64,
}

/// A single HTTP request to execute against a target.
///
/// The authorization context is expressed entirely through the `headers` field — set an
/// `Authorization` header for bearer tokens, API keys, or Basic auth. No special-case auth
/// fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeDefinition {
    /// Fully-qualified target URL including scheme, host, path, and any query parameters.
    pub url: String,
    /// HTTP method for the request.
    #[serde(with = "method_serde")]
    pub method: Method,
    /// Request headers, including any authorization context.
    #[serde(with = "header_map_serde")]
    pub headers: HeaderMap,
    /// Request body. `None` for GET, HEAD, DELETE; `Some` for POST, PATCH, PUT.
    #[serde(with = "opt_bytes_serde")]
    pub body: Option<Bytes>,
}

/// Paired response surfaces for differential analysis.
///
/// `baseline` holds responses for the control input (e.g. a known-existing resource ID).
/// `probe` holds responses for the variable input (e.g. a randomly generated nonexistent ID).
/// Multiple samples per side support statistical analysis for timing oracles.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeSet {
    /// Responses for the known-valid / control input.
    pub baseline: Vec<ResponseSurface>,
    /// Responses for the unknown / suspect input.
    pub probe: Vec<ResponseSurface>,
}

/// The oracle class being probed.
///
/// Each variant corresponds to a distinct detection strategy and analysis pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum OracleClass {
    /// Status-code or body differential between an existing and nonexistent resource.
    Existence,
}

/// Confidence level of an oracle detection result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum OracleVerdict {
    /// Signal is unambiguous: differential is consistent, statistically significant, and matches
    /// a known oracle pattern.
    Confirmed,
    /// Signal is present and consistent with an oracle, but evidence is not conclusive (e.g.
    /// borderline p-value, single sample).
    Likely,
    /// Signal is present but too weak or inconsistent to classify.
    Inconclusive,
    /// No differential signal detected; the endpoint does not exhibit this oracle.
    NotPresent,
}

/// Severity of a confirmed or likely oracle.
///
/// `None` on an `OracleResult` when the verdict is `NotPresent` or `Inconclusive`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Severity {
    /// Directly actionable: resource existence, valid credentials, or session state is leaked
    /// to unauthenticated or low-privilege callers.
    High,
    /// Leaks internal state but requires additional steps to exploit.
    Medium,
    /// Informational: leaks metadata that may assist further enumeration.
    Low,
}

/// Machine-readable record of a single header that differed between baseline and probe sides.
///
/// `baseline` and `probe` are `None` when the header was absent on that side, `Some` when present.
/// A header absent on one side and present on the other is itself a differential signal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffedHeader {
    /// Lowercase header name, e.g. `"x-rate-limit-remaining"`.
    pub name: String,
    /// Header value on the baseline side; `None` if the header was absent.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub baseline: Option<String>,
    /// Header value on the probe side; `None` if the header was absent.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub probe: Option<String>,
}

/// Per-side response summary for machine-readable oracle output.
///
/// Intentionally minimal — carries only `status` now. Will grow when body diffing lands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResponseSummary {
    /// HTTP status code as a `u16`.
    pub status: u16,
}

/// The result of running an oracle analyzer against a `ProbeSet`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleResult {
    /// Which oracle class produced this result.
    pub class: OracleClass,
    /// Confidence verdict.
    pub verdict: OracleVerdict,
    /// Human-readable descriptions of each signal contributing to the verdict, e.g.
    /// `"403 (baseline) vs 404 (probe)"`.
    pub evidence: Vec<String>,
    /// Severity when the verdict is `Confirmed` or `Likely`; `None` when `NotPresent`.
    pub severity: Option<Severity>,
    /// Human-readable name for the detected pattern, e.g. `"Authorization-based differential"`.
    /// `None` when no oracle is detected.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub label: Option<String>,
    /// What information the oracle leaks, e.g.
    /// `"Resource existence confirmed to low-privilege callers"`.
    /// `None` when no oracle is detected.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub leaks: Option<String>,
    /// RFC section grounding the behavior, e.g. `"RFC 9110 §15.5.4"`.
    /// `None` when no oracle is detected.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub rfc_basis: Option<String>,
    /// Response summary for the baseline side; `None` when not populated by the analyzer.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub baseline_summary: Option<ResponseSummary>,
    /// Response summary for the probe side; `None` when not populated by the analyzer.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub probe_summary: Option<ResponseSummary>,
    /// Headers that differed between baseline and probe — the cross-cutting differential signal.
    /// Empty when no header differential was detected or the analyzer did not populate it.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub header_diffs: Vec<DiffedHeader>,
}

/// Errors produced by parlov crates.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// HTTP-level error from the probe engine.
    #[error("http error: {0}")]
    Http(String),
    /// Analysis failed due to insufficient or malformed probe data.
    #[error("analysis error: {0}")]
    Analysis(String),
    /// Serialization or deserialization failure.
    #[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());
    }
}