rullst-core 12.1.1

Core runtime, routing engine, and telemetry for the Rullst framework suite.
Documentation
//! API endpoints for error explanation, self-healing autofix, and developer diagnostics.

use axum::{
    Json,
    extract::{ConnectInfo, Query},
    response::IntoResponse,
};
use serde::Deserialize;
use std::net::SocketAddr;
use std::path::Path;

#[derive(Deserialize)]
/// Query parameters for requests to fetch an AI-based explanation of an error.
pub struct ExplainQuery {
    file: String,
    #[allow(dead_code)]
    line: u32,
    #[allow(dead_code)]
    err: String,
}

/// Asynchronous endpoint called by the browser to fetch the AI error explanation.
///
/// **Security:** Validates that the target file resides within the project's working
/// directory and is a `.rs` or `.toml` file to prevent path-traversal attacks.
#[cfg_attr(mutants, mutants::skip)]
pub async fn handle_explain(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    Query(query): Query<ExplainQuery>,
) -> impl IntoResponse {
    if !addr.ip().is_loopback() {
        return "Access denied: endpoint only accessible from localhost.".to_string();
    }

    // H-3: Apply the same path traversal guard as handle_autofix
    let project_root = match std::env::current_dir() {
        Ok(cwd) => cwd.canonicalize().unwrap_or(cwd),
        Err(_) => return "Unable to determine project root directory.".to_string(),
    };

    let target_path = std::path::Path::new(&query.file);
    if target_path
        .components()
        .any(|c| c == std::path::Component::ParentDir)
    {
        return "Access denied: Path traversal detected.".to_string();
    }

    let canonical_res = target_path.canonicalize();
    let canonical = match canonical_res {
        Ok(p) if p.starts_with(&project_root) => p,
        _ => return "File not found or access denied.".to_string(),
    };

    let extension = canonical.extension().and_then(|e| e.to_str()).unwrap_or("");
    if extension != "rs" && extension != "toml" {
        return "Access denied: only .rs and .toml files can be inspected.".to_string();
    }

    // Block sensitive files disclosure (e.g. .env*, Foundry.toml, Cargo.toml)
    if let Some(filename) = canonical.file_name().and_then(|f| f.to_str()) {
        if filename.starts_with(".env") || filename == "Foundry.toml" || filename == "Cargo.toml" {
            return "Access denied: sensitive configuration files cannot be inspected.".to_string();
        }
    }

    "AI Engine offline. AI features are now available via the `rullst-ai` crate.".to_string()
}

#[derive(Deserialize)]
/// POST request body payload containing data needed to perform an AI autofix operation.
pub struct AutoFixPayload {
    file_path: String,
    line: u32,
    error_message: String,
}

/// POST endpoint that prompts the LLM to rewrite the file on disk to fix the panic.
///
/// **Security:** This endpoint validates that the target file resides within the
/// project's working directory to prevent path-traversal attacks.
#[cfg_attr(mutants, mutants::skip)]
pub async fn handle_autofix(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    Json(payload): Json<AutoFixPayload>,
) -> impl IntoResponse {
    if !addr.ip().is_loopback() {
        return Json(serde_json::json!({
            "success": false,
            "error": "Access denied: endpoint only accessible from localhost"
        }));
    }

    // 1. Resolve the project root (current working directory)
    let project_root = match std::env::current_dir() {
        Ok(cwd) => match cwd.canonicalize() {
            Ok(canonical) => canonical,
            Err(_) => cwd,
        },
        Err(_) => {
            return Json(serde_json::json!({
                "success": false,
                "error": "Unable to determine project root directory"
            }));
        }
    };

    // 2. Resolve and verify the file is within the project root (prevents path traversal and existence oracles)
    let target_path = Path::new(&payload.file_path);
    if target_path
        .components()
        .any(|c| c == std::path::Component::ParentDir)
    {
        return Json(serde_json::json!({
            "success": false,
            "error": "Access denied: Path traversal detected"
        }));
    }

    let canonical_res = target_path.canonicalize();
    let canonical_target = match canonical_res {
        Ok(p) if p.starts_with(&project_root) => p,
        _ => {
            return Json(serde_json::json!({
                "success": false,
                "error": "File not found or access denied"
            }));
        }
    };

    // 4. Additionally restrict to Rust source files only
    let extension = canonical_target
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("");
    if extension != "rs" && extension != "toml" {
        return Json(serde_json::json!({
            "success": false,
            "error": "Autofix is restricted to .rs and .toml files only"
        }));
    }

    // Block sensitive files disclosure (e.g. .env*, Foundry.toml, Cargo.toml)
    if let Some(filename) = canonical_target.file_name().and_then(|f| f.to_str()) {
        if filename.starts_with(".env") || filename == "Foundry.toml" || filename == "Cargo.toml" {
            return Json(serde_json::json!({
                "success": false,
                "error": "Access denied: sensitive configuration files cannot be modified"
            }));
        }
    }

    match perform_autofix(&payload.file_path, payload.line, &payload.error_message).await {
        Ok(_) => Json(serde_json::json!({ "success": true })),
        Err(e) => Json(serde_json::json!({ "success": false, "error": e.to_string() })),
    }
}

/// POST endpoint that triggers database migration execution from the Ignition Error Console.
#[cfg_attr(mutants, mutants::skip)]
pub async fn handle_run_migrations(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> impl IntoResponse {
    if !addr.ip().is_loopback() {
        return Json(serde_json::json!({
            "success": false,
            "error": "Access denied: endpoint only accessible from localhost"
        }));
    }
    Json(serde_json::json!({
        "success": true,
        "message": "Database migration requested. Run `cargo rullst db:migrate` or use Rullst Studio to apply pending SQL migrations."
    }))
}

#[cfg_attr(mutants, mutants::skip)]
async fn perform_autofix(
    _file_path: &str,
    _line: u32,
    _error_message: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    Err("AI Engine offline. Auto-fix is now available via the `rullst-ai` crate.".into())
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::{ExplainQuery, handle_explain};
    use axum::{
        body::to_bytes,
        extract::{ConnectInfo, Query},
        response::IntoResponse,
    };
    use std::net::SocketAddr;

    async fn explain(peer: &str, file: impl Into<String>) -> String {
        let response = handle_explain(
            ConnectInfo(peer.parse::<SocketAddr>().expect("valid test peer")),
            Query(ExplainQuery {
                file: file.into(),
                line: 1,
                err: "test error".to_string(),
            }),
        )
        .await
        .into_response();
        let body = to_bytes(response.into_body(), 4096)
            .await
            .expect("bounded response body");
        String::from_utf8(body.to_vec()).expect("text response")
    }

    #[tokio::test]
    // TM-STUDIO-04: source inspection denies remote, traversal, sensitive and unsupported paths.
    async fn source_inspection_rejects_untrusted_file_requests() {
        let source = std::env::current_dir()
            .expect("workspace directory")
            .join("src/lib.rs");
        assert!(
            explain("192.0.2.30:43000", source.to_string_lossy())
                .await
                .contains("localhost")
        );
        assert!(
            explain("127.0.0.1:43000", "../Cargo.toml")
                .await
                .contains("Path traversal")
        );

        let manifest = std::env::current_dir()
            .expect("workspace directory")
            .join("Cargo.toml");
        assert!(
            explain("127.0.0.1:43000", manifest.to_string_lossy())
                .await
                .contains("sensitive configuration")
        );
        let unsupported_file = std::env::current_dir()
            .expect("workspace directory")
            .join("README.md");
        let unsupported = explain("127.0.0.1:43000", unsupported_file.to_string_lossy()).await;
        assert!(
            unsupported.contains("only .rs and .toml"),
            "unexpected response: {unsupported}"
        );
    }
}