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