rullst_core/error_console/
api.rs1use 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)]
13pub struct ExplainQuery {
15 file: String,
16 #[allow(dead_code)]
17 line: u32,
18 #[allow(dead_code)]
19 err: String,
20}
21
22#[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 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 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)]
71pub struct AutoFixPayload {
73 file_path: String,
74 line: u32,
75 error_message: String,
76}
77
78#[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 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 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 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 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#[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 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}