parlov 0.3.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
//! RFC-compliant DELETE test server for integration testing.
//!
//! Simulates resource-delete endpoints per RFC 9110 §15.5.6. Returns 405 Method Not
//! Allowed (with `Allow: GET, HEAD, PATCH`) when the resource is known, and 404 Not
//! Found when unknown, on the `/resource/{id}` route. The `/normalized/{id}` route
//! always returns 404 regardless of ID. Each spawned instance binds to a random port
//! on 127.0.0.1.

#![deny(clippy::all)]

use axum::{
    Router,
    extract::Path,
    http::{HeaderValue, StatusCode, header},
    response::{IntoResponse, Response},
    routing::{delete, get},
};
use std::net::SocketAddr;
use tokio::net::TcpListener;

/// Resource IDs the server treats as already-existing.
const KNOWN_IDS: &[&str] = &["1", "42", "100"];

/// `Allow` header value mandated by RFC 9110 §15.5.6 for 405 responses.
const ALLOW_VALUE: &str = "GET, HEAD, PATCH";

fn is_known(id: &str) -> bool {
    KNOWN_IDS.contains(&id)
}

// --- handlers ---

async fn resource_get(_id: Path<String>) -> Response {
    StatusCode::OK.into_response()
}

async fn resource_delete(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        let mut resp = StatusCode::METHOD_NOT_ALLOWED.into_response();
        resp.headers_mut().insert(
            header::ALLOW,
            HeaderValue::from_static(ALLOW_VALUE),
        );
        resp
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn deletable(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        StatusCode::NO_CONTENT.into_response()
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn deletable_200(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        let body = serde_json::json!({"deleted": true, "id": id});
        (StatusCode::OK, axum::Json(body)).into_response()
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn async_delete(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        let body = serde_json::json!({"status": "queued"});
        (StatusCode::ACCEPTED, axum::Json(body)).into_response()
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn has_deps(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        let body = serde_json::json!({
            "error": "conflict",
            "detail": "resource has active dependencies"
        });
        (StatusCode::CONFLICT, axum::Json(body)).into_response()
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn forbidden(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        StatusCode::FORBIDDEN.into_response()
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn normalized(_id: Path<String>) -> Response {
    StatusCode::NOT_FOUND.into_response()
}

fn router() -> Router {
    Router::new()
        .route("/resource/{id}", get(resource_get).delete(resource_delete))
        .route("/deletable/{id}", delete(deletable))
        .route("/deletable-200/{id}", delete(deletable_200))
        .route("/async-delete/{id}", delete(async_delete))
        .route("/has-deps/{id}", delete(has_deps))
        .route("/forbidden/{id}", delete(forbidden))
        .route("/normalized/{id}", delete(normalized))
}

/// Spawns the RFC-compliant DELETE test server on a random port.
/// Returns the bound socket address.
pub async fn spawn() -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0")
        .await
        .expect("failed to bind test server listener");
    let addr = listener
        .local_addr()
        .expect("failed to read local_addr from listener");
    tokio::spawn(async move {
        axum::serve(listener, router())
            .await
            .expect("test server failed");
    });
    addr
}