parlov 0.7.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Sanity-check tests for technique-based redirect-diff routes.
//!
//! Directly probes the test server routes without going through the analysis
//! pipeline. Verifies status codes and Location header values for known (`42`)
//! and unknown (`999`) IDs. All requests use a no-redirect client so 3xx
//! responses are captured as-is rather than followed.

#![deny(clippy::all)]

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

use fixtures::get_server::spawn as spawn_get;
use fixtures::post_server::spawn as spawn_post;
use fixtures::put_server::spawn as spawn_put;

fn no_redirect_client() -> reqwest::Client {
    reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .expect("client build failed")
}

// =============================================================================
// GET slash-append — /rd/slash/{id}
// =============================================================================

#[tokio::test]
async fn get_rd_slash_known_id_returns_301_with_location() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/slash/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on 301");
    assert_eq!(resp.headers()["location"], "/rd/slash/42/");
}

#[tokio::test]
async fn get_rd_slash_unknown_id_returns_404() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/slash/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// =============================================================================
// GET slash-strip — /rd/strip/{id}/
// =============================================================================

#[tokio::test]
async fn get_rd_strip_known_id_returns_301_with_location() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/strip/42/"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on 301");
    assert_eq!(resp.headers()["location"], "/rd/strip/42");
}

#[tokio::test]
async fn get_rd_strip_unknown_id_returns_404() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/strip/999/"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// =============================================================================
// GET case-variation — /RD/CASE/{id}
// =============================================================================

#[tokio::test]
async fn get_rd_case_known_id_returns_301_with_location() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/RD/CASE/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on 301");
    assert_eq!(resp.headers()["location"], "/rd/case/42");
}

#[tokio::test]
async fn get_rd_case_unknown_id_returns_404() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/RD/CASE/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// =============================================================================
// GET double-slash — //rd/double/{id}
// =============================================================================

#[tokio::test]
async fn get_rd_double_known_id_returns_301_with_location() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}//rd/double/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on 301");
    assert_eq!(resp.headers()["location"], "/rd/double/42");
}

#[tokio::test]
async fn get_rd_double_unknown_id_returns_404() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}//rd/double/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// =============================================================================
// GET percent-encoding — /rd/percent/{id}
// =============================================================================

#[tokio::test]
async fn get_rd_percent_known_id_returns_301_with_location() {
    let addr = spawn_get().await;
    // reqwest normalizes %72 (r) → r before sending; route /rd/percent/{id} handles it
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/percent/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on 301");
}

#[tokio::test]
async fn get_rd_percent_unknown_id_returns_404() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/percent/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// =============================================================================
// GET blanket — /rd/blanket/{id} (negative: always 301)
// =============================================================================

#[tokio::test]
async fn get_rd_blanket_known_id_returns_301() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/blanket/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on blanket 301");
}

#[tokio::test]
async fn get_rd_blanket_unknown_id_returns_301() {
    let addr = spawn_get().await;
    let resp = no_redirect_client()
        .get(format!("http://{addr}/rd/blanket/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
    assert!(resp.headers().contains_key("location"), "expected Location on blanket 301");
}

// =============================================================================
// POST post-to-303 — /rd/post-303/{id}
// =============================================================================

#[tokio::test]
async fn post_rd_303_known_id_returns_303_with_location() {
    let addr = spawn_post().await;
    let resp = no_redirect_client()
        .post(format!("http://{addr}/rd/post-303/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::SEE_OTHER);
    assert!(resp.headers().contains_key("location"), "expected Location on 303");
    assert_eq!(resp.headers()["location"], "/rd/result/42");
}

#[tokio::test]
async fn post_rd_303_unknown_id_returns_404() {
    let addr = spawn_post().await;
    let resp = no_redirect_client()
        .post(format!("http://{addr}/rd/post-303/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// --- negative: post-normalized always 201 ---

#[tokio::test]
async fn post_rd_normalized_known_id_returns_201() {
    let addr = spawn_post().await;
    let resp = no_redirect_client()
        .post(format!("http://{addr}/rd/post-normalized/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::CREATED);
}

#[tokio::test]
async fn post_rd_normalized_unknown_id_returns_201() {
    let addr = spawn_post().await;
    let resp = no_redirect_client()
        .post(format!("http://{addr}/rd/post-normalized/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::CREATED);
}

// =============================================================================
// PUT put-to-303 — /rd/put-303/{id}
// =============================================================================

#[tokio::test]
async fn put_rd_303_known_id_returns_303_with_location() {
    let addr = spawn_put().await;
    let resp = no_redirect_client()
        .put(format!("http://{addr}/rd/put-303/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::SEE_OTHER);
    assert!(resp.headers().contains_key("location"), "expected Location on 303");
    assert_eq!(resp.headers()["location"], "/rd/result/42");
}

#[tokio::test]
async fn put_rd_303_unknown_id_returns_404() {
    let addr = spawn_put().await;
    let resp = no_redirect_client()
        .put(format!("http://{addr}/rd/put-303/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}

// --- negative: put-normalized always 201 ---

#[tokio::test]
async fn put_rd_normalized_known_id_returns_201() {
    let addr = spawn_put().await;
    let resp = no_redirect_client()
        .put(format!("http://{addr}/rd/put-normalized/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::CREATED);
}

#[tokio::test]
async fn put_rd_normalized_unknown_id_returns_201() {
    let addr = spawn_put().await;
    let resp = no_redirect_client()
        .put(format!("http://{addr}/rd/put-normalized/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), reqwest::StatusCode::CREATED);
}