Skip to main content

sqlmap_rs/
types.rs

1//! Core type definitions for SQLMap REST API (`sqlmapapi`) payloads.
2//!
3//! Provides strictly-typed request/response structures, a comprehensive
4//! options builder, and multi-format output for scan results.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9// ── Response types ───────────────────────────────────────────────
10
11/// Response when creating a new task.
12#[derive(Debug, Clone, Deserialize)]
13#[non_exhaustive]
14pub struct NewTaskResponse {
15    /// True if task creation succeeded.
16    pub success: bool,
17    /// The unique execution ID assigned to this task.
18    pub taskid: Option<String>,
19    /// Error or informational message.
20    pub message: Option<String>,
21}
22
23/// Generic success response from the API.
24#[derive(Debug, Clone, Deserialize)]
25#[non_exhaustive]
26pub struct BasicResponse {
27    /// True if operation succeeded.
28    pub success: bool,
29    /// Detailed message.
30    pub message: Option<String>,
31}
32
33/// Response containing current execution status.
34#[derive(Debug, Clone, Deserialize)]
35#[non_exhaustive]
36pub struct StatusResponse {
37    /// True if request succeeded.
38    pub success: bool,
39    /// Current engine status ("running", "terminated", etc.).
40    pub status: Option<String>,
41    /// Underlying process exit code (populated on termination).
42    pub returncode: Option<i32>,
43    /// Error or informational message when `success` is false.
44    pub message: Option<String>,
45}
46
47/// A chunk of extracted data reported by the SQLMap engine.
48#[derive(Debug, Clone, Deserialize, Serialize)]
49#[non_exhaustive]
50pub struct SqlmapDataChunk {
51    /// Chunk type: 0 (log), 1 (vulnerabilities), 2 (target info), etc.
52    pub r#type: i32,
53    /// The actual JSON payload chunk.
54    pub value: serde_json::Value,
55}
56
57/// Final payload block returning all gathered data for a task.
58#[derive(Debug, Clone, Deserialize)]
59#[non_exhaustive]
60pub struct DataResponse {
61    /// True if fetch succeeded.
62    pub success: bool,
63    /// The aggregated data chunks representing injection results.
64    pub data: Option<Vec<SqlmapDataChunk>>,
65    /// Array of structured errors from the engine.
66    pub error: Option<Vec<String>>,
67    /// Error or informational message when `success` is false.
68    pub message: Option<String>,
69}
70
71/// A log entry from the sqlmap scan execution.
72#[derive(Debug, Clone, Deserialize, Serialize)]
73#[non_exhaustive]
74pub struct LogEntry {
75    /// Log message text.
76    pub message: String,
77    /// Log level string (e.g. "INFO", "WARNING", "ERROR").
78    pub level: String,
79    /// Timestamp of the log entry.
80    pub time: String,
81}
82
83/// Response from the log endpoint.
84#[derive(Debug, Clone, Deserialize)]
85#[non_exhaustive]
86pub struct LogResponse {
87    /// True if fetch succeeded.
88    pub success: bool,
89    /// Array of log entries.
90    pub log: Option<Vec<LogEntry>>,
91    /// Error or informational message when `success` is false.
92    pub message: Option<String>,
93}
94
95// ── Finding types ────────────────────────────────────────────────
96
97/// A parsed finding representing a confirmed SQL injection.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[non_exhaustive]
100pub struct SqlmapFinding {
101    /// Original parameter attacked.
102    pub parameter: String,
103    /// Classification of injection technique.
104    pub vulnerability_type: String,
105    /// Raw payload executed against the target.
106    pub payload: String,
107    /// Arbitrary engine output block with full details.
108    pub details: serde_json::Value,
109}
110
111impl SqlmapFinding {
112    /// Creates a new finding with the given fields.
113    pub fn new(
114        parameter: impl Into<String>,
115        vulnerability_type: impl Into<String>,
116        payload: impl Into<String>,
117        details: serde_json::Value,
118    ) -> Self {
119        Self {
120            parameter: parameter.into(),
121            vulnerability_type: vulnerability_type.into(),
122            payload: payload.into(),
123            details,
124        }
125    }
126}
127
128impl fmt::Display for SqlmapFinding {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        write!(
131            f,
132            "[SQLi] {vtype} on param '{param}' - payload: {payload}",
133            vtype = self.vulnerability_type,
134            param = self.parameter,
135            payload = self.payload,
136        )
137    }
138}
139
140fn is_meaningful_vulnerability_type(vulnerability_type: &str) -> bool {
141    let trimmed = vulnerability_type.trim();
142    !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("unknown")
143}
144
145fn is_confirmed_finding(vulnerability_type: &str, payload: &str) -> bool {
146    is_meaningful_vulnerability_type(vulnerability_type) && !payload.trim().is_empty()
147}
148
149fn technique_entries(data: &serde_json::Value) -> Vec<&serde_json::Value> {
150    if let Some(arr) = data.as_array() {
151        return arr.iter().collect();
152    }
153    if let Some(map) = data.as_object() {
154        let mut entries: Vec<_> = map.iter().collect();
155        entries.sort_by(
156            |(a, _), (b, _)| match (a.parse::<u64>(), b.parse::<u64>()) {
157                (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
158                (Ok(_), Err(_)) => std::cmp::Ordering::Less,
159                (Err(_), Ok(_)) => std::cmp::Ordering::Greater,
160                (Err(_), Err(_)) => a.cmp(b),
161            },
162        );
163        return entries.into_iter().map(|(_, v)| v).collect();
164    }
165    vec![]
166}
167
168fn push_technique_finding(
169    findings: &mut Vec<SqlmapFinding>,
170    parameter: &str,
171    tech: &serde_json::Value,
172) {
173    let Some(tech_obj) = tech.as_object() else {
174        return;
175    };
176    let vulnerability_type = tech_obj
177        .get("technique")
178        .or_else(|| tech_obj.get("title"))
179        .and_then(|v| v.as_str())
180        .unwrap_or("unknown")
181        .to_string();
182    let payload = tech_obj
183        .get("payload")
184        .and_then(|v| v.as_str())
185        .unwrap_or("")
186        .to_string();
187    if is_confirmed_finding(&vulnerability_type, &payload) {
188        findings.push(SqlmapFinding {
189            parameter: parameter.to_string(),
190            vulnerability_type,
191            payload,
192            details: tech.clone(),
193        });
194    }
195}
196
197impl DataResponse {
198    /// Extract structured findings from the raw data chunks.
199    ///
200    /// Type 1 chunks are sqlmapapi TECHNIQUES payloads: each value entry is an
201    /// injection point with a nested `data[]` of `{title|technique, payload}`.
202    /// Flat legacy objects with top-level `type`/`payload` are still accepted.
203    pub fn findings(&self) -> Vec<SqlmapFinding> {
204        let Some(ref chunks) = self.data else {
205            return vec![];
206        };
207        let mut findings = Vec::new();
208
209        for chunk in chunks {
210            if chunk.r#type != 1 {
211                continue;
212            }
213            for item in technique_entries(&chunk.value) {
214                let Some(obj) = item.as_object() else {
215                    continue;
216                };
217                let parameter = obj
218                    .get("parameter")
219                    .and_then(|v| v.as_str())
220                    .unwrap_or("unknown")
221                    .to_string();
222
223                if let Some(data_val) = obj.get("data") {
224                    if data_val.is_array() || data_val.is_object() {
225                        for tech in technique_entries(data_val) {
226                            push_technique_finding(&mut findings, &parameter, tech);
227                        }
228                        continue;
229                    }
230                }
231
232                // Legacy flat shape (non-api fixtures / older payloads).
233                let vulnerability_type = obj
234                    .get("type")
235                    .or_else(|| obj.get("title"))
236                    .and_then(|v| v.as_str())
237                    .unwrap_or("unknown")
238                    .to_string();
239                let payload = obj
240                    .get("payload")
241                    .and_then(|v| v.as_str())
242                    .unwrap_or("")
243                    .to_string();
244                if is_confirmed_finding(&vulnerability_type, &payload) {
245                    findings.push(SqlmapFinding {
246                        parameter,
247                        vulnerability_type,
248                        payload,
249                        details: item.clone(),
250                    });
251                }
252            }
253        }
254
255        findings
256    }
257}
258
259// ── Output formatting ────────────────────────────────────────────
260
261/// Supported output formats for scan results.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263#[non_exhaustive]
264pub enum OutputFormat {
265    /// Compact single-line JSON.
266    Json,
267    /// Pretty-printed JSON.
268    JsonPretty,
269    /// Comma-separated values with header row.
270    Csv,
271    /// GitHub-flavored Markdown table.
272    Markdown,
273    /// Human-readable plain text report.
274    Plain,
275}
276
277/// Format a slice of findings in the specified output format.
278pub fn format_findings(findings: &[SqlmapFinding], format: OutputFormat) -> String {
279    match format {
280        OutputFormat::Json => {
281            // SqlmapFinding is serde-infallible in practice; see gap/property tests.
282            serde_json::to_string(findings).unwrap_or_else(|_| "[]".into())
283        }
284        OutputFormat::JsonPretty => {
285            serde_json::to_string_pretty(findings).unwrap_or_else(|_| "[]".into())
286        }
287        OutputFormat::Csv => {
288            let mut buf = String::from("parameter,vulnerability_type,payload\n");
289            for f in findings {
290                buf.push_str(&format!(
291                    "{},{},{}\n",
292                    csv_escape(&f.parameter),
293                    csv_escape(&f.vulnerability_type),
294                    csv_escape(&f.payload),
295                ));
296            }
297            buf
298        }
299        OutputFormat::Markdown => {
300            if findings.is_empty() {
301                return "No SQL injection findings.\n".to_string();
302            }
303            let mut buf = String::from("| Parameter | Type | Payload |\n");
304            buf.push_str("|-----------|------|----------|\n");
305            for f in findings {
306                buf.push_str(&format!(
307                    "| `{}` | `{}` | `{}` |\n",
308                    markdown_escape(&f.parameter),
309                    markdown_escape(&f.vulnerability_type),
310                    markdown_escape(&f.payload),
311                ));
312            }
313            buf
314        }
315        OutputFormat::Plain => {
316            if findings.is_empty() {
317                return "No SQL injection findings detected.\n".to_string();
318            }
319            let mut buf = format!("=== {} SQLi Finding(s) ===\n\n", findings.len());
320            for (i, f) in findings.iter().enumerate() {
321                buf.push_str(&format!(
322                    "#{} {} on param '{}'\n  Payload: {}\n\n",
323                    i + 1,
324                    f.vulnerability_type,
325                    f.parameter,
326                    f.payload,
327                ));
328            }
329            buf
330        }
331    }
332}
333
334/// Escape a value for CSV output.
335fn csv_escape(value: &str) -> String {
336    if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
337        format!("\"{}\"", value.replace('"', "\"\""))
338    } else {
339        value.to_string()
340    }
341}
342
343/// Escape characters that break GFM table cells. Backticks become `&#96;` because
344/// backslash escapes are not honored inside CommonMark code spans.
345fn markdown_escape(value: &str) -> String {
346    value
347        .replace('\\', "\\\\")
348        .replace('\r', " ")
349        .replace('\n', "<br>")
350        .replace('|', "\\|")
351        .replace('`', "&#96;")
352}
353
354// ── SqlmapOptions ────────────────────────────────────────────────
355
356/// Configuration payload mapped directly to SQLMap CLI arguments.
357///
358/// All fields are optional and use `skip_serializing_if` so only
359/// explicitly set values are sent to the REST API.
360///
361/// # Examples
362///
363/// ```rust
364/// use sqlmap_rs::SqlmapOptions;
365///
366/// let opts = SqlmapOptions::builder()
367///     .url("http://example.com/api?id=1")
368///     .level(3)
369///     .risk(2)
370///     .batch(true)
371///     .threads(4)
372///     .build();
373/// ```
374#[derive(Debug, Clone, Serialize, Default)]
375#[non_exhaustive]
376pub struct SqlmapOptions {
377    // ── Target ──
378    /// The target URL.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub url: Option<String>,
381
382    /// Target specific parameter(s), e.g. "id".
383    #[serde(rename = "testParameter", skip_serializing_if = "Option::is_none")]
384    pub test_parameter: Option<String>,
385
386    // ── Detection ──
387    /// Specific DBMS backend, e.g. "MySQL".
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub dbms: Option<String>,
390
391    /// Payload techniques to test (B=Boolean, E=Error, U=UNION, S=Stacked, T=Time, Q=Inline query).
392    #[serde(rename = "technique", skip_serializing_if = "Option::is_none")]
393    pub tech: Option<String>,
394
395    /// Level of tests to perform (1-5, default 1).
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub level: Option<i32>,
398
399    /// Payload risk (1-3, default 1).
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub risk: Option<i32>,
402
403    /// String to match for True on boolean-based blind injection.
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub string: Option<String>,
406
407    /// String to match for False on boolean-based blind injection.
408    #[serde(rename = "notString", skip_serializing_if = "Option::is_none")]
409    pub not_string: Option<String>,
410
411    /// Regex to match for True on boolean-based blind injection.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub regexp: Option<String>,
414
415    /// HTTP code to match for True query.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub code: Option<i32>,
418
419    /// Compare responses using text only.
420    #[serde(rename = "textOnly", skip_serializing_if = "Option::is_none")]
421    pub text_only: Option<bool>,
422
423    /// Compare responses using titles only.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub titles: Option<bool>,
426
427    // ── Request ──
428    /// HTTP Cookie header value.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub cookie: Option<String>,
431
432    /// HTTP headers string.
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub headers: Option<String>,
435
436    /// Force specific HTTP method.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub method: Option<String>,
439
440    /// POST data string.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub data: Option<String>,
443
444    /// Use randomly selected User-Agent.
445    #[serde(rename = "randomAgent", skip_serializing_if = "Option::is_none")]
446    pub random_agent: Option<bool>,
447
448    /// HTTP proxy URL.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub proxy: Option<String>,
451
452    // ── Injection ──
453    /// Injection payload prefix string.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub prefix: Option<String>,
456
457    /// Injection payload suffix string.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub suffix: Option<String>,
460
461    /// Tamper script(s) for WAF evasion.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub tamper: Option<String>,
464
465    /// Skip testing specific parameters.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub skip: Option<String>,
468
469    /// Skip testing parameters that appear static.
470    #[serde(rename = "skipStatic", skip_serializing_if = "Option::is_none")]
471    pub skip_static: Option<bool>,
472
473    // ── Performance ──
474    /// Number of concurrent threads (default 1).
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub threads: Option<i32>,
477
478    /// Output verbosity level (1-6).
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub verbose: Option<i32>,
481
482    /// Do not ask for user input (must be true for automation).
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub batch: Option<bool>,
485
486    /// Number of retries on connection timeout.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub retries: Option<i32>,
489
490    // ── Enumeration ──
491    /// Enumerate DBMS databases.
492    #[serde(rename = "getDbs", skip_serializing_if = "Option::is_none")]
493    pub get_dbs: Option<bool>,
494
495    /// Enumerate DBMS database tables.
496    #[serde(rename = "getTables", skip_serializing_if = "Option::is_none")]
497    pub get_tables: Option<bool>,
498
499    /// Enumerate DBMS database columns.
500    #[serde(rename = "getColumns", skip_serializing_if = "Option::is_none")]
501    pub get_columns: Option<bool>,
502
503    /// Enumerate DBMS users.
504    #[serde(rename = "getUsers", skip_serializing_if = "Option::is_none")]
505    pub get_users: Option<bool>,
506
507    /// Enumerate DBMS users password hashes.
508    #[serde(rename = "getPasswordHashes", skip_serializing_if = "Option::is_none")]
509    pub get_passwords: Option<bool>,
510
511    /// Enumerate DBMS users privileges.
512    #[serde(rename = "getPrivileges", skip_serializing_if = "Option::is_none")]
513    pub get_privileges: Option<bool>,
514
515    /// Check if the DBMS user is DBA.
516    #[serde(rename = "isDba", skip_serializing_if = "Option::is_none")]
517    pub is_dba: Option<bool>,
518
519    /// Retrieve the current DBMS user.
520    #[serde(rename = "getCurrentUser", skip_serializing_if = "Option::is_none")]
521    pub current_user: Option<bool>,
522
523    /// Retrieve the current DBMS database.
524    #[serde(rename = "getCurrentDb", skip_serializing_if = "Option::is_none")]
525    pub current_db: Option<bool>,
526
527    /// Dump all DBMS databases tables entries.
528    #[serde(rename = "dumpAll", skip_serializing_if = "Option::is_none")]
529    pub dump_all: Option<bool>,
530
531    /// Dump DBMS database table entries.
532    #[serde(rename = "dumpTable", skip_serializing_if = "Option::is_none")]
533    pub dump_table: Option<bool>,
534
535    /// Search for database/table/column names.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub search: Option<bool>,
538
539    // ── OS Access ──
540    /// Prompt for an interactive OS shell.
541    #[serde(rename = "osShell", skip_serializing_if = "Option::is_none")]
542    pub os_shell: Option<bool>,
543
544    /// Prompt for an interactive SQL shell.
545    #[serde(rename = "sqlShell", skip_serializing_if = "Option::is_none")]
546    pub sql_shell: Option<bool>,
547
548    /// Read a file from the DBMS file system.
549    #[serde(rename = "fileRead", skip_serializing_if = "Option::is_none")]
550    pub file_read: Option<String>,
551
552    /// Write a file to the DBMS file system.
553    #[serde(rename = "fileWrite", skip_serializing_if = "Option::is_none")]
554    pub file_write: Option<String>,
555
556    /// Destination path for file write on the DBMS.
557    #[serde(rename = "fileDest", skip_serializing_if = "Option::is_none")]
558    pub file_dest: Option<String>,
559
560    // ── Networking ──
561    /// Use Tor for anonymity.
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub tor: Option<bool>,
564
565    /// Tor proxy port.
566    #[serde(rename = "torPort", skip_serializing_if = "Option::is_none")]
567    pub tor_port: Option<i32>,
568
569    /// Tor proxy type (HTTP, SOCKS4, SOCKS5).
570    #[serde(rename = "torType", skip_serializing_if = "Option::is_none")]
571    pub tor_type: Option<String>,
572
573    // ── Crawling ──
574    /// Crawl the website from the target URL to given depth.
575    #[serde(rename = "crawlDepth", skip_serializing_if = "Option::is_none")]
576    pub crawl_depth: Option<i32>,
577
578    /// Regex to filter target URLs during crawling.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub scope: Option<String>,
581
582    /// Parse and test forms on target pages.
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub forms: Option<bool>,
585
586    // ── Second-order ──
587    /// URL for second-order injection verification.
588    #[serde(rename = "secondUrl", skip_serializing_if = "Option::is_none")]
589    pub second_url: Option<String>,
590}
591
592/// Builder for constructing [`SqlmapOptions`] with a fluent API.
593///
594/// Every field has a corresponding setter method. Call [`.build()`](SqlmapOptionsBuilder::build)
595/// to finalize.
596#[derive(Debug, Clone, Default)]
597pub struct SqlmapOptionsBuilder {
598    inner: SqlmapOptions,
599}
600
601impl SqlmapOptions {
602    /// Create a new options builder.
603    pub fn builder() -> SqlmapOptionsBuilder {
604        SqlmapOptionsBuilder::default()
605    }
606}
607
608/// Macro to generate builder methods for Option<T> fields.
609macro_rules! builder_method {
610    ($name:ident, $field:ident, String) => {
611        /// Sets the `$name` option.
612        pub fn $name(mut self, value: impl Into<String>) -> Self {
613            self.inner.$field = Some(value.into());
614            self
615        }
616    };
617    ($name:ident, $field:ident, bool) => {
618        /// Sets the `$name` option.
619        pub fn $name(mut self, value: bool) -> Self {
620            self.inner.$field = Some(value);
621            self
622        }
623    };
624    ($name:ident, $field:ident, i32) => {
625        /// Sets the `$name` option.
626        pub fn $name(mut self, value: i32) -> Self {
627            self.inner.$field = Some(value);
628            self
629        }
630    };
631}
632
633impl SqlmapOptionsBuilder {
634    // Target
635    builder_method!(url, url, String);
636    builder_method!(test_parameter, test_parameter, String);
637
638    // Detection
639    builder_method!(dbms, dbms, String);
640    builder_method!(tech, tech, String);
641    builder_method!(level, level, i32);
642    builder_method!(risk, risk, i32);
643    builder_method!(string, string, String);
644    builder_method!(not_string, not_string, String);
645    builder_method!(regexp, regexp, String);
646    builder_method!(code, code, i32);
647    builder_method!(text_only, text_only, bool);
648    builder_method!(titles, titles, bool);
649
650    // Request
651    builder_method!(cookie, cookie, String);
652    builder_method!(headers, headers, String);
653    builder_method!(method, method, String);
654    builder_method!(data, data, String);
655    builder_method!(random_agent, random_agent, bool);
656    builder_method!(proxy, proxy, String);
657
658    // Injection
659    builder_method!(prefix, prefix, String);
660    builder_method!(suffix, suffix, String);
661    builder_method!(tamper, tamper, String);
662    builder_method!(skip, skip, String);
663    builder_method!(skip_static, skip_static, bool);
664
665    // Performance
666    builder_method!(threads, threads, i32);
667    builder_method!(verbose, verbose, i32);
668    builder_method!(batch, batch, bool);
669    builder_method!(retries, retries, i32);
670
671    // Enumeration
672    builder_method!(get_dbs, get_dbs, bool);
673    builder_method!(get_tables, get_tables, bool);
674    builder_method!(get_columns, get_columns, bool);
675    builder_method!(get_users, get_users, bool);
676    builder_method!(get_passwords, get_passwords, bool);
677    builder_method!(get_privileges, get_privileges, bool);
678    builder_method!(is_dba, is_dba, bool);
679    builder_method!(current_user, current_user, bool);
680    builder_method!(current_db, current_db, bool);
681    builder_method!(dump_all, dump_all, bool);
682    builder_method!(dump_table, dump_table, bool);
683    builder_method!(search, search, bool);
684
685    // OS Access
686    builder_method!(os_shell, os_shell, bool);
687    builder_method!(sql_shell, sql_shell, bool);
688    builder_method!(file_read, file_read, String);
689    builder_method!(file_write, file_write, String);
690    builder_method!(file_dest, file_dest, String);
691
692    // Networking
693    builder_method!(tor, tor, bool);
694    builder_method!(tor_port, tor_port, i32);
695    builder_method!(tor_type, tor_type, String);
696
697    // Crawling
698    builder_method!(crawl_depth, crawl_depth, i32);
699    builder_method!(scope, scope, String);
700    builder_method!(forms, forms, bool);
701
702    // Second-order
703    builder_method!(second_url, second_url, String);
704
705    /// Finalize and return the configured [`SqlmapOptions`].
706    pub fn build(self) -> SqlmapOptions {
707        self.inner
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn empty_data_response_gives_no_findings() {
717        let resp = DataResponse {
718            success: true,
719            data: None,
720            error: None,
721            message: None,
722        };
723        assert!(resp.findings().is_empty());
724    }
725
726    #[test]
727    fn type_0_chunks_ignored() {
728        let resp = DataResponse {
729            success: true,
730            data: Some(vec![SqlmapDataChunk {
731                r#type: 0,
732                value: serde_json::json!("log message"),
733            }]),
734            error: None,
735            message: None,
736        };
737        assert!(resp.findings().is_empty());
738    }
739
740    #[test]
741    fn type_1_nested_techniques_expanded() {
742        let resp = DataResponse {
743            success: true,
744            data: Some(vec![SqlmapDataChunk {
745                r#type: 1,
746                value: serde_json::json!([{
747                    "place": "GET",
748                    "parameter": "id",
749                    "data": [
750                        {
751                            "title": "AND boolean-based blind - WHERE or HAVING clause",
752                            "payload": "id=1 AND 8888=8888"
753                        },
754                        {
755                            "technique": "time-based blind",
756                            "payload": "id=1 AND SLEEP(5)"
757                        }
758                    ]
759                }]),
760            }]),
761            error: None,
762            message: None,
763        };
764        let findings = resp.findings();
765        assert_eq!(findings.len(), 2);
766        assert_eq!(findings[0].parameter, "id");
767        assert_eq!(
768            findings[0].vulnerability_type,
769            "AND boolean-based blind - WHERE or HAVING clause"
770        );
771        assert_eq!(findings[0].payload, "id=1 AND 8888=8888");
772        assert_eq!(findings[1].vulnerability_type, "time-based blind");
773        assert_eq!(findings[1].payload, "id=1 AND SLEEP(5)");
774    }
775
776    #[test]
777    fn type_1_chunk_parsed_as_finding() {
778        let resp = DataResponse {
779            success: true,
780            data: Some(vec![SqlmapDataChunk {
781                r#type: 1,
782                value: serde_json::json!([{
783                    "parameter": "id",
784                    "type": "boolean-based blind",
785                    "payload": "id=1 AND 1=1"
786                }]),
787            }]),
788            error: None,
789            message: None,
790        };
791        let findings = resp.findings();
792        assert_eq!(findings.len(), 1);
793        assert_eq!(findings[0].parameter, "id");
794        assert_eq!(findings[0].vulnerability_type, "boolean-based blind");
795    }
796
797    #[test]
798    fn builder_pattern_serializes_correctly() {
799        let opts = SqlmapOptions::builder()
800            .url("http://test.com?id=1")
801            .level(3)
802            .risk(2)
803            .batch(true)
804            .threads(4)
805            .tamper("space2comment")
806            .build();
807        let json = serde_json::to_string(&opts).expect("serialize");
808        assert!(json.contains("http://test.com"));
809        assert!(json.contains("\"level\":3"));
810        assert!(json.contains("\"threads\":4"));
811        assert!(json.contains("space2comment"));
812        // None fields should be skipped.
813        assert!(!json.contains("dbms"));
814    }
815
816    #[test]
817    fn tech_serializes_as_technique() {
818        let opts = SqlmapOptions::builder().tech("BEUSTQ").build();
819        let json = serde_json::to_string(&opts).expect("serialize");
820        assert!(json.contains("\"technique\":\"BEUSTQ\""));
821        assert!(!json.contains("\"tech\""));
822    }
823
824    #[test]
825    fn type_1_chunk_edge_cases() {
826        let resp = DataResponse {
827            success: true,
828            data: Some(vec![SqlmapDataChunk {
829                r#type: 1,
830                value: serde_json::json!([
831                    { "parameter": "username" },
832                    "string_instead_of_object_should_be_ignored",
833                    { "type": "error-based" },
834                    {
835                        "parameter": "id",
836                        "type": "boolean-based blind",
837                        "payload": "id=1 AND 1=1"
838                    }
839                ]),
840            }]),
841            error: None,
842            message: None,
843        };
844        let findings = resp.findings();
845        assert_eq!(findings.len(), 1);
846        assert_eq!(findings[0].parameter, "id");
847        assert_eq!(findings[0].vulnerability_type, "boolean-based blind");
848        assert_eq!(findings[0].payload, "id=1 AND 1=1");
849    }
850
851    #[test]
852    fn new_options_fields_serialize() {
853        let opts = SqlmapOptions::builder()
854            .tor(true)
855            .tor_port(9050)
856            .tor_type("SOCKS5")
857            .crawl_depth(3)
858            .second_url("http://verify.com")
859            .tamper("between,randomcase")
860            .retries(5)
861            .dump_all(true)
862            .file_read("/etc/passwd")
863            .build();
864        let json = serde_json::to_string(&opts).expect("serialize");
865        assert!(json.contains("\"tor\":true"));
866        assert!(json.contains("\"torPort\":9050"));
867        assert!(json.contains("\"crawlDepth\":3"));
868        assert!(json.contains("\"secondUrl\""));
869        assert!(json.contains("\"fileRead\""));
870        assert!(json.contains("\"dumpAll\":true"));
871    }
872
873    #[test]
874    fn finding_display() {
875        let finding = SqlmapFinding {
876            parameter: "id".into(),
877            vulnerability_type: "boolean-based blind".into(),
878            payload: "id=1 AND 1=1".into(),
879            details: serde_json::json!({}),
880        };
881        let display = format!("{finding}");
882        assert!(display.contains("boolean-based blind"));
883        assert!(display.contains("id"));
884    }
885
886    #[test]
887    fn format_csv_output() {
888        let findings = vec![SqlmapFinding {
889            parameter: "id".into(),
890            vulnerability_type: "error-based".into(),
891            payload: "' OR 1=1--".into(),
892            details: serde_json::json!({}),
893        }];
894        let csv = format_findings(&findings, OutputFormat::Csv);
895        assert!(csv.starts_with("parameter,vulnerability_type,payload\n"));
896        assert!(csv.contains("error-based"));
897    }
898
899    #[test]
900    fn format_plain_empty() {
901        let plain = format_findings(&[], OutputFormat::Plain);
902        assert_eq!(plain, "No SQL injection findings detected.\n");
903    }
904
905    #[test]
906    fn format_markdown_output() {
907        let findings = vec![SqlmapFinding {
908            parameter: "id".into(),
909            vulnerability_type: "UNION query".into(),
910            payload: "id=1 UNION SELECT 1,2--".into(),
911            details: serde_json::json!({}),
912        }];
913        let md = format_findings(&findings, OutputFormat::Markdown);
914        assert!(md.contains("| Parameter |"));
915        assert!(md.contains("UNION query"));
916    }
917}