Skip to main content

sqlmap_rs/
client.rs

1//! Orchestrator for the `sqlmapapi.py` subprocess and its RESTful interface.
2//!
3//! Manages the full daemon lifecycle — boot, health check, task creation,
4//! scan execution, log retrieval, graceful stop/kill, and RAII cleanup.
5
6use crate::error::SqlmapError;
7use crate::types::{
8    BasicResponse, DataResponse, LogResponse, NewTaskResponse, SqlmapOptions, StatusResponse,
9};
10use reqwest::Client;
11use std::process::Stdio;
12use std::time::Duration;
13use tokio::process::{Child, Command};
14use tokio::time::sleep;
15use tracing::{debug, warn};
16
17fn map_json_error(err: reqwest::Error) -> SqlmapError {
18    if err.is_decode() {
19        SqlmapError::MalformedResponse
20    } else {
21        SqlmapError::RequestError(err)
22    }
23}
24
25/// Manages the `sqlmapapi` lifecycle and provides access to its REST API.
26///
27/// When the engine is dropped, the locally spawned daemon subprocess
28/// is killed automatically via RAII (best-effort).
29pub struct SqlmapEngine {
30    api_url: String,
31    http: Client,
32    daemon_process: Option<Child>,
33    /// Configurable polling interval for `wait_for_completion`.
34    poll_interval: Duration,
35}
36
37impl SqlmapEngine {
38    /// Launches a local `sqlmapapi` daemon bound to `127.0.0.1`.
39    ///
40    /// # Arguments
41    ///
42    /// * `port` — TCP port for the daemon. If `0` is passed with `spawn_local`,
43    ///   the OS assigns an ephemeral port (not yet supported by sqlmapapi).
44    /// * `spawn_local` — If true, spawns a local `sqlmapapi` subprocess.
45    /// * `binary_path` — Override the `sqlmapapi` binary location.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`SqlmapError::ProcessError`] if the daemon fails to spawn,
50    /// or [`SqlmapError::ApiError`] if it doesn't become responsive within 5 seconds.
51    pub async fn new(
52        port: u16,
53        spawn_local: bool,
54        binary_path: Option<&str>,
55    ) -> Result<Self, SqlmapError> {
56        Self::with_config(
57            port,
58            spawn_local,
59            binary_path,
60            Duration::from_secs(10),
61            Duration::from_millis(1000),
62        )
63        .await
64    }
65
66    /// Launches a daemon with custom HTTP timeout and polling interval.
67    ///
68    /// # Arguments
69    ///
70    /// * `request_timeout` — HTTP request timeout for API calls.
71    /// * `poll_interval` — Interval between status polls in `wait_for_completion`.
72    pub async fn with_config(
73        port: u16,
74        spawn_local: bool,
75        binary_path: Option<&str>,
76        request_timeout: Duration,
77        poll_interval: Duration,
78    ) -> Result<Self, SqlmapError> {
79        if poll_interval.is_zero() {
80            return Err(SqlmapError::ApiError(
81                "poll_interval must be greater than zero".into(),
82            ));
83        }
84
85        if spawn_local && port == 0 {
86            return Err(SqlmapError::ApiError(
87                "port 0 is not supported when spawning local sqlmapapi".into(),
88            ));
89        }
90
91        let mut daemon_process = None;
92        let api_url = format!("http://127.0.0.1:{port}");
93
94        let http = Client::builder().timeout(request_timeout).build()?;
95
96        if spawn_local {
97            // Check if port is already in use before spawning.
98            if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() {
99                return Err(SqlmapError::PortConflict { port });
100            }
101
102            let binary = binary_path.unwrap_or("sqlmapapi");
103
104            let mut cmd = Command::new(binary);
105            cmd.arg("-s")
106                .arg("-H")
107                .arg("127.0.0.1")
108                .arg("-p")
109                .arg(port.to_string())
110                .kill_on_drop(true);
111
112            cmd.stdout(Stdio::null()).stderr(Stdio::null());
113
114            daemon_process = Some(cmd.spawn().map_err(|e| {
115                if e.kind() == std::io::ErrorKind::NotFound {
116                    SqlmapError::BinaryNotFound(binary.to_string())
117                } else {
118                    SqlmapError::ProcessError(e)
119                }
120            })?);
121
122            // Wait for daemon to become responsive with a health probe.
123            let mut ready = false;
124            for attempt in 0..20 {
125                if let Ok(resp) = http.get(format!("{api_url}/task/new")).send().await {
126                    if let Ok(json) = resp.json::<NewTaskResponse>().await.map_err(map_json_error) {
127                        if json.success {
128                            if let Some(task_id) = json.taskid {
129                                // Clean up the probe task.
130                                let _ = http
131                                    .get(format!("{api_url}/task/{task_id}/delete"))
132                                    .send()
133                                    .await;
134                                ready = true;
135                                break;
136                            }
137                        }
138                    }
139                }
140                debug!(attempt, "waiting for sqlmapapi daemon to become ready");
141                sleep(Duration::from_millis(250)).await;
142            }
143
144            if !ready {
145                return Err(SqlmapError::ApiError(
146                    "sqlmapapi daemon failed to become responsive within 5 seconds".into(),
147                ));
148            }
149        }
150
151        Ok(Self {
152            api_url,
153            http,
154            daemon_process,
155            poll_interval,
156        })
157    }
158
159    /// Creates and configures a new scanning task, returning an RAII wrapper.
160    ///
161    /// The task is automatically deleted from the daemon when dropped.
162    pub async fn create_task(
163        &self,
164        options: &SqlmapOptions,
165    ) -> Result<SqlmapTask<'_>, SqlmapError> {
166        let uri = format!("{}/task/new", self.api_url);
167        let resp = self
168            .http
169            .get(uri)
170            .send()
171            .await?
172            .json::<NewTaskResponse>()
173            .await
174            .map_err(map_json_error)?;
175
176        if !resp.success {
177            return Err(SqlmapError::ApiError(
178                resp.message
179                    .unwrap_or_else(|| "task creation returned success=false".into()),
180            ));
181        }
182
183        let task_id = resp
184            .taskid
185            .filter(|id| !id.is_empty())
186            .ok_or_else(|| SqlmapError::InvalidTask(String::new()))?;
187
188        let task = SqlmapTask {
189            engine: self,
190            task_id,
191        };
192
193        // Set the configuration options on the new task.
194        let set_uri = format!("{}/option/{}/set", self.api_url, task.task_id);
195        let set_resp = self
196            .http
197            .post(&set_uri)
198            .json(options)
199            .send()
200            .await?
201            .json::<BasicResponse>()
202            .await
203            .map_err(map_json_error)?;
204
205        if !set_resp.success {
206            return Err(SqlmapError::ApiError(
207                set_resp
208                    .message
209                    .unwrap_or_else(|| "option configuration failed".into()),
210            ));
211        }
212
213        Ok(task)
214    }
215
216    /// Check if sqlmapapi is available on this system.
217    ///
218    /// Tests that the `sqlmapapi` binary exists and is executable.
219    /// Does NOT fall back to `python3 -c "import sqlmap"` since that
220    /// doesn't guarantee the REST API server is available.
221    pub fn is_available() -> bool {
222        std::process::Command::new("sqlmapapi")
223            .arg("-h")
224            .stdout(Stdio::null())
225            .stderr(Stdio::null())
226            .status()
227            .map(|s| s.success())
228            .unwrap_or(false)
229    }
230
231    /// Check if sqlmapapi is available, trying the provided binary path first.
232    pub fn is_available_at(binary_path: &str) -> bool {
233        std::process::Command::new(binary_path)
234            .arg("-h")
235            .stdout(Stdio::null())
236            .stderr(Stdio::null())
237            .status()
238            .map(|s| s.success())
239            .unwrap_or(false)
240    }
241
242    /// Returns the base API URL for this engine.
243    pub fn api_url(&self) -> &str {
244        &self.api_url
245    }
246}
247
248impl Drop for SqlmapEngine {
249    fn drop(&mut self) {
250        if let Some(mut proc) = self.daemon_process.take() {
251            let _ = proc.start_kill();
252        }
253    }
254}
255
256// ── SqlmapTask ───────────────────────────────────────────────────
257
258/// An RAII-tracked scan execution task.
259///
260/// Ensures that the daemon reclaims task memory on drop by sending a
261/// delete request when a Tokio runtime is available (best-effort).
262pub struct SqlmapTask<'a> {
263    engine: &'a SqlmapEngine,
264    task_id: String,
265}
266
267impl<'a> SqlmapTask<'a> {
268    /// Returns the unique task ID assigned by the daemon.
269    pub fn task_id(&self) -> &str {
270        &self.task_id
271    }
272
273    /// Starts the SQL injection scan on this task.
274    ///
275    /// The URL and options must have been configured via [`SqlmapEngine::create_task`].
276    pub async fn start(&self) -> Result<(), SqlmapError> {
277        let uri = format!("{}/scan/{}/start", self.engine.api_url, self.task_id);
278        let payload = serde_json::json!({});
279        let resp = self.engine.http.post(&uri).json(&payload).send().await?;
280
281        if resp.status().is_success() {
282            let body = resp.json::<BasicResponse>().await.map_err(map_json_error)?;
283            if !body.success {
284                return Err(SqlmapError::ApiError(
285                    body.message
286                        .unwrap_or_else(|| "scan start returned success=false".into()),
287                ));
288            }
289            Ok(())
290        } else {
291            Err(SqlmapError::ApiError(format!(
292                "scan start returned HTTP {}",
293                resp.status()
294            )))
295        }
296    }
297
298    /// Polls the task status until completion or timeout.
299    ///
300    /// Uses the engine's configured poll interval (default: 1 second).
301    pub async fn wait_for_completion(&self, timeout_secs: u64) -> Result<(), SqlmapError> {
302        let uri = format!("{}/scan/{}/status", self.engine.api_url, self.task_id);
303        let start = std::time::Instant::now();
304
305        loop {
306            if start.elapsed().as_secs() > timeout_secs {
307                return Err(SqlmapError::Timeout(timeout_secs));
308            }
309
310            let resp = self.engine.http.get(&uri).send().await?;
311
312            if !resp.status().is_success() {
313                return Err(SqlmapError::ApiError(format!(
314                    "status check returned HTTP {}",
315                    resp.status()
316                )));
317            }
318
319            let status = resp
320                .json::<StatusResponse>()
321                .await
322                .map_err(map_json_error)?;
323
324            if !status.success {
325                return Err(SqlmapError::ApiError(
326                    status
327                        .message
328                        .unwrap_or_else(|| "status check returned success=false".into()),
329                ));
330            }
331
332            match status.status.as_deref() {
333                Some("running") => {
334                    debug!(task_id = %self.task_id, "scan running");
335                }
336                Some("terminated") => {
337                    if let Some(code) = status.returncode {
338                        if code != 0 {
339                            return Err(SqlmapError::ApiError(format!(
340                                "scan terminated with non-zero exit code {code}"
341                            )));
342                        }
343                    }
344                    return Ok(());
345                }
346                Some("not running") => {
347                    // sqlmapapi reports "not running" before start attaches a
348                    // process AND after some finished states. Keep polling;
349                    // only `terminated` is a definitive completion.
350                    debug!(task_id = %self.task_id, "scan not running yet");
351                }
352                Some(other) => {
353                    warn!(task_id = %self.task_id, status = %other, "unknown sqlmap status");
354                }
355                None => {}
356            }
357
358            sleep(self.engine.poll_interval).await;
359        }
360    }
361
362    /// Fetches the compiled data results from the engine.
363    pub async fn fetch_data(&self) -> Result<DataResponse, SqlmapError> {
364        let uri = format!("{}/scan/{}/data", self.engine.api_url, self.task_id);
365        let resp = self.engine.http.get(uri).send().await?;
366
367        if resp.status().is_success() {
368            let data = resp.json::<DataResponse>().await.map_err(map_json_error)?;
369            if !data.success {
370                return Err(SqlmapError::ApiError(
371                    data.message
372                        .unwrap_or_else(|| "data fetch returned success=false".into()),
373                ));
374            }
375            Ok(data)
376        } else {
377            Err(SqlmapError::ApiError(format!(
378                "data fetch returned HTTP {}",
379                resp.status()
380            )))
381        }
382    }
383
384    /// Fetches execution log entries for this task.
385    ///
386    /// Useful for monitoring what sqlmap is doing during a scan.
387    pub async fn fetch_log(&self) -> Result<LogResponse, SqlmapError> {
388        let uri = format!("{}/scan/{}/log", self.engine.api_url, self.task_id);
389        let resp = self.engine.http.get(uri).send().await?;
390
391        if resp.status().is_success() {
392            let log = resp.json::<LogResponse>().await.map_err(map_json_error)?;
393            if !log.success {
394                return Err(SqlmapError::ApiError(
395                    log.message
396                        .unwrap_or_else(|| "log fetch returned success=false".into()),
397                ));
398            }
399            Ok(log)
400        } else {
401            Err(SqlmapError::ApiError(format!(
402                "log fetch returned HTTP {}",
403                resp.status()
404            )))
405        }
406    }
407
408    /// Gracefully stops a running scan.
409    ///
410    /// The task can potentially be restarted after stopping.
411    pub async fn stop(&self) -> Result<(), SqlmapError> {
412        let uri = format!("{}/scan/{}/stop", self.engine.api_url, self.task_id);
413        let resp = self.engine.http.get(uri).send().await?;
414
415        if resp.status().is_success() {
416            let body = resp.json::<BasicResponse>().await.map_err(map_json_error)?;
417            if !body.success {
418                return Err(SqlmapError::ApiError(
419                    body.message
420                        .unwrap_or_else(|| "scan stop returned success=false".into()),
421                ));
422            }
423            Ok(())
424        } else {
425            Err(SqlmapError::ApiError(format!(
426                "scan stop returned HTTP {}",
427                resp.status()
428            )))
429        }
430    }
431
432    /// Forcefully kills a running scan.
433    ///
434    /// The task is terminated immediately. Data collected up to this point
435    /// may still be retrievable via [`fetch_data`](Self::fetch_data).
436    pub async fn kill(&self) -> Result<(), SqlmapError> {
437        let uri = format!("{}/scan/{}/kill", self.engine.api_url, self.task_id);
438        let resp = self.engine.http.get(uri).send().await?;
439
440        if resp.status().is_success() {
441            let body = resp.json::<BasicResponse>().await.map_err(map_json_error)?;
442            if !body.success {
443                return Err(SqlmapError::ApiError(
444                    body.message
445                        .unwrap_or_else(|| "scan kill returned success=false".into()),
446                ));
447            }
448            Ok(())
449        } else {
450            Err(SqlmapError::ApiError(format!(
451                "scan kill returned HTTP {}",
452                resp.status()
453            )))
454        }
455    }
456
457    /// Retrieves the current option values configured for this task.
458    pub async fn list_options(&self) -> Result<serde_json::Value, SqlmapError> {
459        let uri = format!("{}/option/{}/list", self.engine.api_url, self.task_id);
460        let resp = self.engine.http.get(uri).send().await?;
461
462        if resp.status().is_success() {
463            let value = resp
464                .json::<serde_json::Value>()
465                .await
466                .map_err(map_json_error)?;
467            if value
468                .get("success")
469                .and_then(|v| v.as_bool())
470                .is_some_and(|success| !success)
471            {
472                let message = value
473                    .get("message")
474                    .and_then(|v| v.as_str())
475                    .map(str::to_string)
476                    .unwrap_or_else(|| "option list returned success=false".into());
477                return Err(SqlmapError::ApiError(message));
478            }
479            Ok(value)
480        } else {
481            Err(SqlmapError::ApiError(format!(
482                "option list returned HTTP {}",
483                resp.status()
484            )))
485        }
486    }
487}
488
489impl<'a> Drop for SqlmapTask<'a> {
490    fn drop(&mut self) {
491        // Guarantee the server reclaims task memory when this struct goes out of scope.
492        // We use Handle::try_current() to avoid panicking if no Tokio runtime is active.
493        let uri = format!("{}/task/{}/delete", self.engine.api_url, self.task_id);
494        let client = self.engine.http.clone();
495
496        if let Ok(handle) = tokio::runtime::Handle::try_current() {
497            handle.spawn(async move {
498                let _ = client.get(&uri).send().await;
499            });
500        }
501        // If no runtime is available, we skip cleanup silently.
502        // The daemon will reclaim the task when it shuts down.
503    }
504}