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