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 daemon subprocess (if locally spawned)
20/// is killed automatically via RAII.
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 or connects to an existing remote one.
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. Provides the full scan lifecycle: start → poll → fetch.
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                    // Task was created but not started, or already finished.
308                    return Ok(());
309                }
310                Some(other) => {
311                    warn!(task_id = %self.task_id, status = %other, "unknown sqlmap status");
312                }
313                None => {}
314            }
315
316            sleep(self.engine.poll_interval).await;
317        }
318    }
319
320    /// Fetches the compiled data results from the engine.
321    pub async fn fetch_data(&self) -> Result<DataResponse, SqlmapError> {
322        let uri = format!("{}/scan/{}/data", self.engine.api_url, self.task_id);
323        let resp = self.engine.http.get(uri).send().await?;
324
325        if resp.status().is_success() {
326            Ok(resp.json::<DataResponse>().await?)
327        } else {
328            Err(SqlmapError::ApiError(format!(
329                "data fetch returned HTTP {}",
330                resp.status()
331            )))
332        }
333    }
334
335    /// Fetches execution log entries for this task.
336    ///
337    /// Useful for monitoring what sqlmap is doing during a scan.
338    pub async fn fetch_log(&self) -> Result<LogResponse, SqlmapError> {
339        let uri = format!("{}/scan/{}/log", self.engine.api_url, self.task_id);
340        let resp = self.engine.http.get(uri).send().await?;
341
342        if resp.status().is_success() {
343            Ok(resp.json::<LogResponse>().await?)
344        } else {
345            Err(SqlmapError::ApiError(format!(
346                "log fetch returned HTTP {}",
347                resp.status()
348            )))
349        }
350    }
351
352    /// Gracefully stops a running scan.
353    ///
354    /// The task can potentially be restarted after stopping.
355    pub async fn stop(&self) -> Result<(), SqlmapError> {
356        let uri = format!("{}/scan/{}/stop", self.engine.api_url, self.task_id);
357        let resp = self
358            .engine
359            .http
360            .get(uri)
361            .send()
362            .await?
363            .json::<BasicResponse>()
364            .await?;
365
366        if !resp.success {
367            return Err(SqlmapError::ApiError(
368                resp.message
369                    .unwrap_or_else(|| "scan stop returned success=false".into()),
370            ));
371        }
372        Ok(())
373    }
374
375    /// Forcefully kills a running scan.
376    ///
377    /// The task is terminated immediately. Data collected up to this point
378    /// may still be retrievable via [`fetch_data`](Self::fetch_data).
379    pub async fn kill(&self) -> Result<(), SqlmapError> {
380        let uri = format!("{}/scan/{}/kill", self.engine.api_url, self.task_id);
381        let resp = self
382            .engine
383            .http
384            .get(uri)
385            .send()
386            .await?
387            .json::<BasicResponse>()
388            .await?;
389
390        if !resp.success {
391            return Err(SqlmapError::ApiError(
392                resp.message
393                    .unwrap_or_else(|| "scan kill returned success=false".into()),
394            ));
395        }
396        Ok(())
397    }
398
399    /// Retrieves the current option values configured for this task.
400    pub async fn list_options(&self) -> Result<serde_json::Value, SqlmapError> {
401        let uri = format!("{}/option/{}/list", 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            Ok(resp.json::<serde_json::Value>().await?)
406        } else {
407            Err(SqlmapError::ApiError(format!(
408                "option list returned HTTP {}",
409                resp.status()
410            )))
411        }
412    }
413}
414
415impl<'a> Drop for SqlmapTask<'a> {
416    fn drop(&mut self) {
417        // Guarantee the server reclaims task memory when this struct goes out of scope.
418        // We use Handle::try_current() to avoid panicking if no Tokio runtime is active.
419        let uri = format!(
420            "{}/task/{}/delete",
421            self.engine.api_url, self.task_id
422        );
423        let client = self.engine.http.clone();
424
425        if let Ok(handle) = tokio::runtime::Handle::try_current() {
426            handle.spawn(async move {
427                let _ = client.get(&uri).send().await;
428            });
429        }
430        // If no runtime is available, we skip cleanup silently.
431        // The daemon will reclaim the task when it shuts down.
432    }
433}