Skip to main content

rullst_core/error_console/
api.rs

1//! API endpoints for error explanation, self-healing autofix, and developer diagnostics.
2
3use axum::{
4    Json,
5    extract::{ConnectInfo, Query},
6    response::IntoResponse,
7};
8use serde::Deserialize;
9use std::net::SocketAddr;
10use std::path::Path;
11
12#[derive(Deserialize)]
13/// Query parameters for requests to fetch an AI-based explanation of an error.
14pub struct ExplainQuery {
15    file: String,
16    #[allow(dead_code)]
17    line: u32,
18    #[allow(dead_code)]
19    err: String,
20}
21
22/// Asynchronous endpoint called by the browser to fetch the AI error explanation.
23///
24/// **Security:** Validates that the target file resides within the project's working
25/// directory and is a `.rs` or `.toml` file to prevent path-traversal attacks.
26#[cfg_attr(mutants, mutants::skip)]
27pub async fn handle_explain(
28    ConnectInfo(addr): ConnectInfo<SocketAddr>,
29    Query(query): Query<ExplainQuery>,
30) -> impl IntoResponse {
31    if !addr.ip().is_loopback() {
32        return "Access denied: endpoint only accessible from localhost.".to_string();
33    }
34
35    // H-3: Apply the same path traversal guard as handle_autofix
36    let project_root = match std::env::current_dir() {
37        Ok(cwd) => cwd.canonicalize().unwrap_or(cwd),
38        Err(_) => return "Unable to determine project root directory.".to_string(),
39    };
40
41    let target_path = std::path::Path::new(&query.file);
42    if target_path
43        .components()
44        .any(|c| c == std::path::Component::ParentDir)
45    {
46        return "Access denied: Path traversal detected.".to_string();
47    }
48
49    let canonical_res = target_path.canonicalize();
50    let canonical = match canonical_res {
51        Ok(p) if p.starts_with(&project_root) => p,
52        _ => return "File not found or access denied.".to_string(),
53    };
54
55    let extension = canonical.extension().and_then(|e| e.to_str()).unwrap_or("");
56    if extension != "rs" && extension != "toml" {
57        return "Access denied: only .rs and .toml files can be inspected.".to_string();
58    }
59
60    // Block sensitive files disclosure (e.g. .env*, Foundry.toml, Cargo.toml)
61    if let Some(filename) = canonical.file_name().and_then(|f| f.to_str()) {
62        if filename.starts_with(".env") || filename == "Foundry.toml" || filename == "Cargo.toml" {
63            return "Access denied: sensitive configuration files cannot be inspected.".to_string();
64        }
65    }
66
67    "AI Engine offline. AI features are now available via the `rullst-ai` crate.".to_string()
68}
69
70#[derive(Deserialize)]
71/// POST request body payload containing data needed to perform an AI autofix operation.
72pub struct AutoFixPayload {
73    file_path: String,
74    line: u32,
75    error_message: String,
76}
77
78/// POST endpoint that prompts the LLM to rewrite the file on disk to fix the panic.
79///
80/// **Security:** This endpoint validates that the target file resides within the
81/// project's working directory to prevent path-traversal attacks.
82#[cfg_attr(mutants, mutants::skip)]
83pub async fn handle_autofix(
84    ConnectInfo(addr): ConnectInfo<SocketAddr>,
85    Json(payload): Json<AutoFixPayload>,
86) -> impl IntoResponse {
87    if !addr.ip().is_loopback() {
88        return Json(serde_json::json!({
89            "success": false,
90            "error": "Access denied: endpoint only accessible from localhost"
91        }));
92    }
93
94    // 1. Resolve the project root (current working directory)
95    let project_root = match std::env::current_dir() {
96        Ok(cwd) => match cwd.canonicalize() {
97            Ok(canonical) => canonical,
98            Err(_) => cwd,
99        },
100        Err(_) => {
101            return Json(serde_json::json!({
102                "success": false,
103                "error": "Unable to determine project root directory"
104            }));
105        }
106    };
107
108    // 2. Resolve and verify the file is within the project root (prevents path traversal and existence oracles)
109    let target_path = Path::new(&payload.file_path);
110    if target_path
111        .components()
112        .any(|c| c == std::path::Component::ParentDir)
113    {
114        return Json(serde_json::json!({
115            "success": false,
116            "error": "Access denied: Path traversal detected"
117        }));
118    }
119
120    let canonical_res = target_path.canonicalize();
121    let canonical_target = match canonical_res {
122        Ok(p) if p.starts_with(&project_root) => p,
123        _ => {
124            return Json(serde_json::json!({
125                "success": false,
126                "error": "File not found or access denied"
127            }));
128        }
129    };
130
131    // 4. Additionally restrict to Rust source files only
132    let extension = canonical_target
133        .extension()
134        .and_then(|e| e.to_str())
135        .unwrap_or("");
136    if extension != "rs" && extension != "toml" {
137        return Json(serde_json::json!({
138            "success": false,
139            "error": "Autofix is restricted to .rs and .toml files only"
140        }));
141    }
142
143    // Block sensitive files disclosure (e.g. .env*, Foundry.toml, Cargo.toml)
144    if let Some(filename) = canonical_target.file_name().and_then(|f| f.to_str()) {
145        if filename.starts_with(".env") || filename == "Foundry.toml" || filename == "Cargo.toml" {
146            return Json(serde_json::json!({
147                "success": false,
148                "error": "Access denied: sensitive configuration files cannot be modified"
149            }));
150        }
151    }
152
153    match perform_autofix(&payload.file_path, payload.line, &payload.error_message).await {
154        Ok(_) => Json(serde_json::json!({ "success": true })),
155        Err(e) => Json(serde_json::json!({ "success": false, "error": e.to_string() })),
156    }
157}
158
159/// POST endpoint that triggers database migration execution from the Ignition Error Console.
160#[cfg_attr(mutants, mutants::skip)]
161pub async fn handle_run_migrations(
162    ConnectInfo(addr): ConnectInfo<SocketAddr>,
163) -> impl IntoResponse {
164    if !addr.ip().is_loopback() {
165        return Json(serde_json::json!({
166            "success": false,
167            "error": "Access denied: endpoint only accessible from localhost"
168        }));
169    }
170    Json(serde_json::json!({
171        "success": true,
172        "message": "Database migration requested. Run `cargo rullst db:migrate` or use Rullst Studio to apply pending SQL migrations."
173    }))
174}
175
176#[cfg_attr(mutants, mutants::skip)]
177async fn perform_autofix(
178    _file_path: &str,
179    _line: u32,
180    _error_message: &str,
181) -> Result<(), Box<dyn std::error::Error>> {
182    Err("AI Engine offline. Auto-fix is now available via the `rullst-ai` crate.".into())
183}
184
185#[cfg(test)]
186#[allow(clippy::expect_used, clippy::unwrap_used)]
187mod tests {
188    use super::{ExplainQuery, handle_explain};
189    use axum::{
190        body::to_bytes,
191        extract::{ConnectInfo, Query},
192        response::IntoResponse,
193    };
194    use std::net::SocketAddr;
195
196    async fn explain(peer: &str, file: impl Into<String>) -> String {
197        let response = handle_explain(
198            ConnectInfo(peer.parse::<SocketAddr>().expect("valid test peer")),
199            Query(ExplainQuery {
200                file: file.into(),
201                line: 1,
202                err: "test error".to_string(),
203            }),
204        )
205        .await
206        .into_response();
207        let body = to_bytes(response.into_body(), 4096)
208            .await
209            .expect("bounded response body");
210        String::from_utf8(body.to_vec()).expect("text response")
211    }
212
213    #[tokio::test]
214    // TM-STUDIO-04: source inspection denies remote, traversal, sensitive and unsupported paths.
215    async fn source_inspection_rejects_untrusted_file_requests() {
216        let source = std::env::current_dir()
217            .expect("workspace directory")
218            .join("src/lib.rs");
219        assert!(
220            explain("192.0.2.30:43000", source.to_string_lossy())
221                .await
222                .contains("localhost")
223        );
224        assert!(
225            explain("127.0.0.1:43000", "../Cargo.toml")
226                .await
227                .contains("Path traversal")
228        );
229
230        let manifest = std::env::current_dir()
231            .expect("workspace directory")
232            .join("Cargo.toml");
233        assert!(
234            explain("127.0.0.1:43000", manifest.to_string_lossy())
235                .await
236                .contains("sensitive configuration")
237        );
238        let unsupported_file = std::env::current_dir()
239            .expect("workspace directory")
240            .join("README.md");
241        let unsupported = explain("127.0.0.1:43000", unsupported_file.to_string_lossy()).await;
242        assert!(
243            unsupported.contains("only .rs and .toml"),
244            "unexpected response: {unsupported}"
245        );
246    }
247}