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