sqlmap-rs 0.2.0

Type-safe asynchronous wrapper for the sqlmap REST API (sqlmapapi) with full lifecycle control, streaming output, and multi-format results
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Orchestrator for the `sqlmapapi.py` subprocess and its RESTful interface.
//!
//! Manages the full daemon lifecycle — boot, health check, task creation,
//! scan execution, log retrieval, graceful stop/kill, and RAII cleanup.

use crate::error::SqlmapError;
use crate::types::{
    BasicResponse, DataResponse, LogResponse, NewTaskResponse, SqlmapOptions, StatusResponse,
};
use reqwest::Client;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::time::sleep;
use tracing::{debug, warn};

/// Manages the `sqlmapapi` lifecycle and provides access to its REST API.
///
/// When the engine is dropped, the daemon subprocess (if locally spawned)
/// is killed automatically via RAII.
pub struct SqlmapEngine {
    api_url: String,
    http: Client,
    daemon_process: Option<Child>,
    /// Configurable polling interval for `wait_for_completion`.
    poll_interval: Duration,
}

impl SqlmapEngine {
    /// Launches a local `sqlmapapi` daemon or connects to an existing remote one.
    ///
    /// # Arguments
    ///
    /// * `port` — TCP port for the daemon. If `0` is passed with `spawn_local`,
    ///   the OS assigns an ephemeral port (not yet supported by sqlmapapi).
    /// * `spawn_local` — If true, spawns a local `sqlmapapi` subprocess.
    /// * `binary_path` — Override the `sqlmapapi` binary location.
    ///
    /// # Errors
    ///
    /// Returns [`SqlmapError::ProcessError`] if the daemon fails to spawn,
    /// or [`SqlmapError::ApiError`] if it doesn't become responsive within 5 seconds.
    pub async fn new(
        port: u16,
        spawn_local: bool,
        binary_path: Option<&str>,
    ) -> Result<Self, SqlmapError> {
        Self::with_config(port, spawn_local, binary_path, Duration::from_secs(10), Duration::from_millis(1000)).await
    }

    /// Launches a daemon with custom HTTP timeout and polling interval.
    ///
    /// # Arguments
    ///
    /// * `request_timeout` — HTTP request timeout for API calls.
    /// * `poll_interval` — Interval between status polls in `wait_for_completion`.
    pub async fn with_config(
        port: u16,
        spawn_local: bool,
        binary_path: Option<&str>,
        request_timeout: Duration,
        poll_interval: Duration,
    ) -> Result<Self, SqlmapError> {
        let mut daemon_process = None;
        let api_url = format!("http://127.0.0.1:{port}");

        let http = Client::builder()
            .timeout(request_timeout)
            .build()?;

        if spawn_local {
            // Check if port is already in use before spawning.
            if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() {
                return Err(SqlmapError::PortConflict { port });
            }

            let binary = binary_path.unwrap_or("sqlmapapi");

            let mut cmd = Command::new(binary);
            cmd.arg("-s")
                .arg("-H").arg("127.0.0.1")
                .arg("-p").arg(port.to_string())
                .kill_on_drop(true);

            cmd.stdout(Stdio::null()).stderr(Stdio::null());

            daemon_process = Some(cmd.spawn()?);

            // Wait for daemon to become responsive with a health probe.
            let mut ready = false;
            for attempt in 0..20 {
                if let Ok(resp) = http.get(format!("{api_url}/task/new")).send().await {
                    if let Ok(json) = resp.json::<NewTaskResponse>().await {
                        if json.success {
                            if let Some(task_id) = json.taskid {
                                // Clean up the probe task.
                                let _ = http
                                    .get(format!("{api_url}/task/{task_id}/delete"))
                                    .send()
                                    .await;
                                ready = true;
                                break;
                            }
                        }
                    }
                }
                debug!(attempt, "waiting for sqlmapapi daemon to become ready");
                sleep(Duration::from_millis(250)).await;
            }

            if !ready {
                return Err(SqlmapError::ApiError(
                    "sqlmapapi daemon failed to become responsive within 5 seconds".into(),
                ));
            }
        }

        Ok(Self {
            api_url,
            http,
            daemon_process,
            poll_interval,
        })
    }

    /// Creates and configures a new scanning task, returning an RAII wrapper.
    ///
    /// The task is automatically deleted from the daemon when dropped.
    pub async fn create_task(&self, options: &SqlmapOptions) -> Result<SqlmapTask<'_>, SqlmapError> {
        let uri = format!("{}/task/new", self.api_url);
        let resp = self
            .http
            .get(uri)
            .send()
            .await?
            .json::<NewTaskResponse>()
            .await?;

        if !resp.success {
            return Err(SqlmapError::ApiError(
                resp.message
                    .unwrap_or_else(|| "task creation returned success=false".into()),
            ));
        }

        let task_id = resp.taskid.ok_or_else(|| {
            SqlmapError::ApiError("task creation succeeded but returned no task ID".into())
        })?;

        if task_id.is_empty() {
            return Err(SqlmapError::ApiError(
                "task creation succeeded but returned empty task ID".into(),
            ));
        }

        let task = SqlmapTask {
            engine: self,
            task_id,
        };

        // Set the configuration options on the new task.
        let set_uri = format!("{}/option/{}/set", self.api_url, task.task_id);
        let set_resp = self
            .http
            .post(&set_uri)
            .json(options)
            .send()
            .await?
            .json::<BasicResponse>()
            .await?;

        if !set_resp.success {
            return Err(SqlmapError::ApiError(
                set_resp
                    .message
                    .unwrap_or_else(|| "option configuration failed".into()),
            ));
        }

        Ok(task)
    }

    /// Check if sqlmapapi is available on this system.
    ///
    /// Tests that the `sqlmapapi` binary exists and is executable.
    /// Does NOT fall back to `python3 -c "import sqlmap"` since that
    /// doesn't guarantee the REST API server is available.
    pub fn is_available() -> bool {
        std::process::Command::new("sqlmapapi")
            .arg("-h")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }

    /// Check if sqlmapapi is available, trying the provided binary path first.
    pub fn is_available_at(binary_path: &str) -> bool {
        std::process::Command::new(binary_path)
            .arg("-h")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }

    /// Returns the base API URL for this engine.
    pub fn api_url(&self) -> &str {
        &self.api_url
    }
}

impl Drop for SqlmapEngine {
    fn drop(&mut self) {
        if let Some(mut proc) = self.daemon_process.take() {
            let _ = proc.start_kill();
        }
    }
}

// ── SqlmapTask ───────────────────────────────────────────────────

/// An RAII-tracked scan execution task.
///
/// Ensures that the daemon reclaims task memory on drop by sending a
/// delete request. Provides the full scan lifecycle: start → poll → fetch.
pub struct SqlmapTask<'a> {
    engine: &'a SqlmapEngine,
    task_id: String,
}

impl<'a> SqlmapTask<'a> {
    /// Returns the unique task ID assigned by the daemon.
    pub fn task_id(&self) -> &str {
        &self.task_id
    }

    /// Starts the SQL injection scan on this task.
    ///
    /// The URL and options must have been configured via [`SqlmapEngine::create_task`].
    pub async fn start(&self) -> Result<(), SqlmapError> {
        let uri = format!("{}/scan/{}/start", self.engine.api_url, self.task_id);
        let payload = serde_json::json!({});
        let resp = self
            .engine
            .http
            .post(&uri)
            .json(&payload)
            .send()
            .await?
            .json::<BasicResponse>()
            .await?;

        if !resp.success {
            return Err(SqlmapError::ApiError(
                resp.message
                    .unwrap_or_else(|| "scan start returned success=false".into()),
            ));
        }
        Ok(())
    }

    /// Polls the task status until completion or timeout.
    ///
    /// Uses the engine's configured poll interval (default: 1 second).
    pub async fn wait_for_completion(&self, timeout_secs: u64) -> Result<(), SqlmapError> {
        let uri = format!("{}/scan/{}/status", self.engine.api_url, self.task_id);
        let start = std::time::Instant::now();

        loop {
            if start.elapsed().as_secs() > timeout_secs {
                return Err(SqlmapError::Timeout(timeout_secs));
            }

            let resp = self
                .engine
                .http
                .get(&uri)
                .send()
                .await?
                .json::<StatusResponse>()
                .await?;

            if !resp.success {
                return Err(SqlmapError::ApiError(
                    "status check returned success=false".into(),
                ));
            }

            match resp.status.as_deref() {
                Some("running") => {
                    debug!(task_id = %self.task_id, "scan running");
                }
                Some("terminated") => {
                    if let Some(code) = resp.returncode {
                        if code != 0 {
                            return Err(SqlmapError::ApiError(format!(
                                "scan terminated with non-zero exit code {code}"
                            )));
                        }
                    }
                    return Ok(());
                }
                Some("not running") => {
                    // Task was created but not started, or already finished.
                    return Ok(());
                }
                Some(other) => {
                    warn!(task_id = %self.task_id, status = %other, "unknown sqlmap status");
                }
                None => {}
            }

            sleep(self.engine.poll_interval).await;
        }
    }

    /// Fetches the compiled data results from the engine.
    pub async fn fetch_data(&self) -> Result<DataResponse, SqlmapError> {
        let uri = format!("{}/scan/{}/data", self.engine.api_url, self.task_id);
        let resp = self.engine.http.get(uri).send().await?;

        if resp.status().is_success() {
            Ok(resp.json::<DataResponse>().await?)
        } else {
            Err(SqlmapError::ApiError(format!(
                "data fetch returned HTTP {}",
                resp.status()
            )))
        }
    }

    /// Fetches execution log entries for this task.
    ///
    /// Useful for monitoring what sqlmap is doing during a scan.
    pub async fn fetch_log(&self) -> Result<LogResponse, SqlmapError> {
        let uri = format!("{}/scan/{}/log", self.engine.api_url, self.task_id);
        let resp = self.engine.http.get(uri).send().await?;

        if resp.status().is_success() {
            Ok(resp.json::<LogResponse>().await?)
        } else {
            Err(SqlmapError::ApiError(format!(
                "log fetch returned HTTP {}",
                resp.status()
            )))
        }
    }

    /// Gracefully stops a running scan.
    ///
    /// The task can potentially be restarted after stopping.
    pub async fn stop(&self) -> Result<(), SqlmapError> {
        let uri = format!("{}/scan/{}/stop", self.engine.api_url, self.task_id);
        let resp = self
            .engine
            .http
            .get(uri)
            .send()
            .await?
            .json::<BasicResponse>()
            .await?;

        if !resp.success {
            return Err(SqlmapError::ApiError(
                resp.message
                    .unwrap_or_else(|| "scan stop returned success=false".into()),
            ));
        }
        Ok(())
    }

    /// Forcefully kills a running scan.
    ///
    /// The task is terminated immediately. Data collected up to this point
    /// may still be retrievable via [`fetch_data`](Self::fetch_data).
    pub async fn kill(&self) -> Result<(), SqlmapError> {
        let uri = format!("{}/scan/{}/kill", self.engine.api_url, self.task_id);
        let resp = self
            .engine
            .http
            .get(uri)
            .send()
            .await?
            .json::<BasicResponse>()
            .await?;

        if !resp.success {
            return Err(SqlmapError::ApiError(
                resp.message
                    .unwrap_or_else(|| "scan kill returned success=false".into()),
            ));
        }
        Ok(())
    }

    /// Retrieves the current option values configured for this task.
    pub async fn list_options(&self) -> Result<serde_json::Value, SqlmapError> {
        let uri = format!("{}/option/{}/list", self.engine.api_url, self.task_id);
        let resp = self.engine.http.get(uri).send().await?;

        if resp.status().is_success() {
            Ok(resp.json::<serde_json::Value>().await?)
        } else {
            Err(SqlmapError::ApiError(format!(
                "option list returned HTTP {}",
                resp.status()
            )))
        }
    }
}

impl<'a> Drop for SqlmapTask<'a> {
    fn drop(&mut self) {
        // Guarantee the server reclaims task memory when this struct goes out of scope.
        // We use Handle::try_current() to avoid panicking if no Tokio runtime is active.
        let uri = format!(
            "{}/task/{}/delete",
            self.engine.api_url, self.task_id
        );
        let client = self.engine.http.clone();

        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            handle.spawn(async move {
                let _ = client.get(&uri).send().await;
            });
        }
        // If no runtime is available, we skip cleanup silently.
        // The daemon will reclaim the task when it shuts down.
    }
}