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