parlov 0.7.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Integration tests for the redirect-diff existence oracle pipeline.
//!
//! Each test spawns an isolated RFC-compliant server, drives the adaptive
//! sampling loop, and asserts the final verdict and severity. Uses a
//! no-redirect HTTP client so 3xx responses are captured as-is.
//!
//! Covers the 8 technique-based strategies: `RdSlashAppend`, `RdSlashStrip`,
//! `RdCaseVariation`, `RdDoubleSlash`, `RdPercentEncoding`, `RdPostTo303`,
//! `RdPutTo303`, and the blanket-redirect negative case.
//! `RdProtocolUpgrade` requires TLS infrastructure not available in the test
//! environment — see `redirect_diff_sanity.rs` for a `#[ignore]` stub.

#![deny(clippy::all)]

mod fixtures {
    pub mod get_server;
    pub mod post_server;
    pub mod put_server;
}

use bytes::Bytes;
use http::{HeaderMap, HeaderValue, Method, header};
use parlov_analysis::existence::ExistenceAnalyzer;
use parlov_analysis::{Analyzer, SampleDecision};
use parlov_core::{
    DifferentialSet, NormativeStrength, OracleClass, OracleResult, OracleVerdict,
    ProbeDefinition, Severity, Technique, Vector,
};
use parlov_probe::http::HttpProbe;
use parlov_probe::Probe;
use reqwest::redirect;

// --- helpers ---

fn json_headers() -> HeaderMap {
    let mut h = HeaderMap::new();
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
    h
}

fn make_def(url: String, method: Method, body: Option<Bytes>) -> ProbeDefinition {
    let headers = if method == Method::GET { HeaderMap::new() } else { json_headers() };
    ProbeDefinition { url, method, headers, body }
}

fn rd_technique() -> Technique {
    Technique {
        id: "test-redirect-diff",
        name: "Test redirect diff",
        oracle_class: OracleClass::Existence,
        vector: Vector::RedirectDiff,
        strength: NormativeStrength::Should,
    }
}

/// Adaptive sampling loop with a no-redirect client.
async fn collect_rd(
    baseline_url: String,
    probe_url: String,
    method: Method,
    body: Option<Bytes>,
) -> OracleResult {
    let client = HttpProbe::with_client(
        reqwest::Client::builder()
            .redirect(redirect::Policy::none())
            .build()
            .expect("client build failed"),
    );
    let analyzer = ExistenceAnalyzer;
    let mut ds = DifferentialSet {
        baseline: Vec::new(),
        probe: Vec::new(),
        technique: rd_technique(),
    };
    loop {
        let bd = make_def(baseline_url.clone(), method.clone(), body.clone());
        let pd = make_def(probe_url.clone(), method.clone(), body.clone());
        ds.baseline.push(client.execute(&bd).await.expect("baseline failed"));
        ds.probe.push(client.execute(&pd).await.expect("probe failed"));
        if let SampleDecision::Complete(result) = analyzer.evaluate(&ds) {
            return *result;
        }
    }
}

/// GET redirect-diff collection helper. Substitutes `{id}` in `path`.
async fn get_rd(addr: std::net::SocketAddr, path: &str) -> OracleResult {
    collect_rd(
        format!("http://{addr}{}", path.replace("{id}", "42")),
        format!("http://{addr}{}", path.replace("{id}", "999")),
        Method::GET,
        None,
    )
    .await
}

/// POST redirect-diff collection helper. Substitutes `{id}` in `path`.
async fn post_rd(addr: std::net::SocketAddr, path: &str) -> OracleResult {
    collect_rd(
        format!("http://{addr}{}", path.replace("{id}", "42")),
        format!("http://{addr}{}", path.replace("{id}", "999")),
        Method::POST,
        Some(Bytes::from_static(b"{}")),
    )
    .await
}

/// PUT redirect-diff collection helper. Substitutes `{id}` in `path`.
async fn put_rd(addr: std::net::SocketAddr, path: &str) -> OracleResult {
    collect_rd(
        format!("http://{addr}{}", path.replace("{id}", "42")),
        format!("http://{addr}{}", path.replace("{id}", "999")),
        Method::PUT,
        Some(Bytes::from_static(b"{\"name\":\"updated\"}")),
    )
    .await
}

// ── RdSlashAppend — strategy strips trailing slash; server redirects existing ──

/// Positive: `GET /rd/slash/42` → 301 (known), `GET /rd/slash/999` → 404 → Confirmed.
#[tokio::test]
async fn rd_slash_append_301_vs_404_confirmed() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/slash/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: blanket 301 for all IDs → NotPresent.
#[tokio::test]
async fn rd_slash_append_blanket_not_present() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/blanket/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdSlashStrip — strategy appends trailing slash; server redirects existing ──

/// Positive: `GET /rd/strip/42/` → 301, `GET /rd/strip/999/` → 404 → Confirmed.
#[tokio::test]
async fn rd_slash_strip_301_vs_404_confirmed() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/strip/{id}/").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: blanket 301 → NotPresent.
#[tokio::test]
async fn rd_slash_strip_blanket_not_present() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/blanket/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdCaseVariation — strategy uppercases path; server redirects to lowercase ──

/// Positive: `GET /RD/CASE/42` → 301, `GET /RD/CASE/999` → 404 → Confirmed.
#[tokio::test]
async fn rd_case_variation_301_vs_404_confirmed() {
    let r = get_rd(fixtures::get_server::spawn().await, "/RD/CASE/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: blanket 301 → NotPresent.
#[tokio::test]
async fn rd_case_variation_blanket_not_present() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/blanket/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdDoubleSlash — strategy inserts // at path start; server collapses ──

/// Positive: `GET //rd/double/42` → 301, `GET //rd/double/999` → 404 → Confirmed.
#[tokio::test]
async fn rd_double_slash_301_vs_404_confirmed() {
    let r = get_rd(fixtures::get_server::spawn().await, "//rd/double/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: blanket 301 → NotPresent.
#[tokio::test]
async fn rd_double_slash_blanket_not_present() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/blanket/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdPercentEncoding — strategy encodes first alnum; server normalizes ──
//
// reqwest normalizes percent-encoded unreserved chars before sending, so
// `/%72d/percent/{id}` arrives as `/rd/percent/{id}`. The differential is
// preserved because the ID segment is not encoded.

/// Positive: `/rd/percent/42` → 301, `/rd/percent/999` → 404 → Confirmed.
#[tokio::test]
async fn rd_percent_encoding_301_vs_404_confirmed() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/percent/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: blanket 301 → NotPresent.
#[tokio::test]
async fn rd_percent_encoding_blanket_not_present() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/blanket/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdPostTo303 — PRG POST; server 303 for known, 404 for unknown ──

/// Positive: `POST /rd/post-303/42` → 303, `POST /rd/post-303/999` → 404 → Confirmed.
#[tokio::test]
async fn rd_post_to_303_confirmed() {
    let r = post_rd(fixtures::post_server::spawn().await, "/rd/post-303/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: `POST /rd/post-normalized/{id}` always 201 → NotPresent.
#[tokio::test]
async fn rd_post_to_303_normalized_not_present() {
    let r = post_rd(fixtures::post_server::spawn().await, "/rd/post-normalized/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdPutTo303 — PUT with redirect; server 303 for known, 404 for unknown ──

/// Positive: `PUT /rd/put-303/42` → 303, `PUT /rd/put-303/999` → 404 → Confirmed.
#[tokio::test]
async fn rd_put_to_303_confirmed() {
    let r = put_rd(fixtures::put_server::spawn().await, "/rd/put-303/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(r.severity.is_some());
}

/// Negative: `PUT /rd/put-normalized/{id}` always 201 → NotPresent.
#[tokio::test]
async fn rd_put_to_303_normalized_not_present() {
    let r = put_rd(fixtures::put_server::spawn().await, "/rd/put-normalized/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::NotPresent);
    assert_eq!(r.severity, None);
}

// ── RdProtocolUpgrade — stub (requires TLS infrastructure) ──

/// HTTP→HTTPS protocol-upgrade redirect detection requires TLS infrastructure
/// not available in the test environment. The strategy is tested at the unit
/// level (URL generation) but the full pipeline test is skipped here.
#[tokio::test]
#[ignore = "RdProtocolUpgrade requires TLS infrastructure not available in test environment"]
async fn rd_protocol_upgrade_stub() {
    // This test intentionally left empty. See docs/existenceOracle/RedirectDiff/
    // for the expected behavior: HTTP request to a known resource triggers a
    // 301/302 redirect to the HTTPS canonical URL; unknown resource returns 404.
}

// ── Severity spot-check ──

/// The 301 vs 404 differential with a Location header on the baseline side
/// should produce at least Severity::Low (redirect-based differentials).
#[tokio::test]
async fn rd_slash_append_severity_at_least_low() {
    let r = get_rd(fixtures::get_server::spawn().await, "/rd/slash/{id}").await;
    assert_eq!(r.verdict, OracleVerdict::Confirmed);
    assert!(
        r.severity == Some(Severity::Low)
            || r.severity == Some(Severity::Medium)
            || r.severity == Some(Severity::High),
        "expected at least Low severity, got {:?}",
        r.severity
    );
}