use axum::{
Json,
extract::{ConnectInfo, Query},
response::IntoResponse,
};
use serde::Deserialize;
use std::net::SocketAddr;
use std::path::Path;
#[derive(Deserialize)]
pub struct ExplainQuery {
file: String,
#[allow(dead_code)]
line: u32,
#[allow(dead_code)]
err: String,
}
#[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();
}
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();
}
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)]
pub struct AutoFixPayload {
file_path: String,
line: u32,
error_message: String,
}
#[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"
}));
}
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"
}));
}
};
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"
}));
}
};
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"
}));
}
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() })),
}
}
#[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]
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}"
);
}
}