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