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