parlov 0.4.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! RFC-compliant PATCH test server for integration testing.
//!
//! Simulates resource-patch endpoints per RFC 5789 and RFC 9110. Returns
//! method-specific status codes when the resource exists and 404 (or 201)
//! when it does not, depending on the route. Each spawned instance binds
//! to a random port on 127.0.0.1.

#![deny(clippy::all)]

use axum::{
    Router,
    extract::Path,
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::patch,
};
use serde_json::json;
use std::net::SocketAddr;
use tokio::net::TcpListener;

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

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

// --- handlers ---

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

async fn creating(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        StatusCode::UNPROCESSABLE_ENTITY.into_response()
    } else {
        StatusCode::CREATED.into_response()
    }
}

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

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

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

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

async fn precondition(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        StatusCode::PRECONDITION_FAILED.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}", patch(resource))
        .route("/creating/{id}", patch(creating))
        .route("/success/{id}", patch(success))
        .route("/no-content/{id}", patch(no_content))
        .route("/conflict/{id}", patch(conflict))
        .route("/media/{id}", patch(media))
        .route("/precondition/{id}", patch(precondition))
        .route("/normalized/{id}", patch(normalized))
}

/// Spawns the RFC-compliant PATCH 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
}