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