1use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, Deserialize)]
13#[non_exhaustive]
14pub struct NewTaskResponse {
15 pub success: bool,
17 pub taskid: Option<String>,
19 pub message: Option<String>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
25#[non_exhaustive]
26pub struct BasicResponse {
27 pub success: bool,
29 pub message: Option<String>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35#[non_exhaustive]
36pub struct StatusResponse {
37 pub success: bool,
39 pub status: Option<String>,
41 pub returncode: Option<i32>,
43 pub message: Option<String>,
45}
46
47#[derive(Debug, Clone, Deserialize, Serialize)]
49#[non_exhaustive]
50pub struct SqlmapDataChunk {
51 pub r#type: i32,
53 pub value: serde_json::Value,
55}
56
57#[derive(Debug, Clone, Deserialize)]
59#[non_exhaustive]
60pub struct DataResponse {
61 pub success: bool,
63 pub data: Option<Vec<SqlmapDataChunk>>,
65 pub error: Option<Vec<String>>,
67 pub message: Option<String>,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
73#[non_exhaustive]
74pub struct LogEntry {
75 pub message: String,
77 pub level: String,
79 pub time: String,
81}
82
83#[derive(Debug, Clone, Deserialize)]
85#[non_exhaustive]
86pub struct LogResponse {
87 pub success: bool,
89 pub log: Option<Vec<LogEntry>>,
91 pub message: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
99#[non_exhaustive]
100pub struct SqlmapFinding {
101 pub parameter: String,
103 pub vulnerability_type: String,
105 pub payload: String,
107 pub details: serde_json::Value,
109}
110
111impl SqlmapFinding {
112 pub fn new(
114 parameter: impl Into<String>,
115 vulnerability_type: impl Into<String>,
116 payload: impl Into<String>,
117 details: serde_json::Value,
118 ) -> Self {
119 Self {
120 parameter: parameter.into(),
121 vulnerability_type: vulnerability_type.into(),
122 payload: payload.into(),
123 details,
124 }
125 }
126}
127
128impl fmt::Display for SqlmapFinding {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 write!(
131 f,
132 "[SQLi] {vtype} on param '{param}' - payload: {payload}",
133 vtype = self.vulnerability_type,
134 param = self.parameter,
135 payload = self.payload,
136 )
137 }
138}
139
140fn is_meaningful_vulnerability_type(vulnerability_type: &str) -> bool {
141 let trimmed = vulnerability_type.trim();
142 !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("unknown")
143}
144
145fn is_confirmed_finding(vulnerability_type: &str, payload: &str) -> bool {
146 is_meaningful_vulnerability_type(vulnerability_type) && !payload.trim().is_empty()
147}
148
149fn technique_entries(data: &serde_json::Value) -> Vec<&serde_json::Value> {
150 if let Some(arr) = data.as_array() {
151 return arr.iter().collect();
152 }
153 if let Some(map) = data.as_object() {
154 let mut entries: Vec<_> = map.iter().collect();
155 entries.sort_by(
156 |(a, _), (b, _)| match (a.parse::<u64>(), b.parse::<u64>()) {
157 (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
158 (Ok(_), Err(_)) => std::cmp::Ordering::Less,
159 (Err(_), Ok(_)) => std::cmp::Ordering::Greater,
160 (Err(_), Err(_)) => a.cmp(b),
161 },
162 );
163 return entries.into_iter().map(|(_, v)| v).collect();
164 }
165 vec![]
166}
167
168fn push_technique_finding(
169 findings: &mut Vec<SqlmapFinding>,
170 parameter: &str,
171 tech: &serde_json::Value,
172) {
173 let Some(tech_obj) = tech.as_object() else {
174 return;
175 };
176 let vulnerability_type = tech_obj
177 .get("technique")
178 .or_else(|| tech_obj.get("title"))
179 .and_then(|v| v.as_str())
180 .unwrap_or("unknown")
181 .to_string();
182 let payload = tech_obj
183 .get("payload")
184 .and_then(|v| v.as_str())
185 .unwrap_or("")
186 .to_string();
187 if is_confirmed_finding(&vulnerability_type, &payload) {
188 findings.push(SqlmapFinding {
189 parameter: parameter.to_string(),
190 vulnerability_type,
191 payload,
192 details: tech.clone(),
193 });
194 }
195}
196
197impl DataResponse {
198 pub fn findings(&self) -> Vec<SqlmapFinding> {
204 let Some(ref chunks) = self.data else {
205 return vec![];
206 };
207 let mut findings = Vec::new();
208
209 for chunk in chunks {
210 if chunk.r#type != 1 {
211 continue;
212 }
213 for item in technique_entries(&chunk.value) {
214 let Some(obj) = item.as_object() else {
215 continue;
216 };
217 let parameter = obj
218 .get("parameter")
219 .and_then(|v| v.as_str())
220 .unwrap_or("unknown")
221 .to_string();
222
223 if let Some(data_val) = obj.get("data") {
224 if data_val.is_array() || data_val.is_object() {
225 for tech in technique_entries(data_val) {
226 push_technique_finding(&mut findings, ¶meter, tech);
227 }
228 continue;
229 }
230 }
231
232 let vulnerability_type = obj
234 .get("type")
235 .or_else(|| obj.get("title"))
236 .and_then(|v| v.as_str())
237 .unwrap_or("unknown")
238 .to_string();
239 let payload = obj
240 .get("payload")
241 .and_then(|v| v.as_str())
242 .unwrap_or("")
243 .to_string();
244 if is_confirmed_finding(&vulnerability_type, &payload) {
245 findings.push(SqlmapFinding {
246 parameter,
247 vulnerability_type,
248 payload,
249 details: item.clone(),
250 });
251 }
252 }
253 }
254
255 findings
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263#[non_exhaustive]
264pub enum OutputFormat {
265 Json,
267 JsonPretty,
269 Csv,
271 Markdown,
273 Plain,
275}
276
277pub fn format_findings(findings: &[SqlmapFinding], format: OutputFormat) -> String {
279 match format {
280 OutputFormat::Json => {
281 serde_json::to_string(findings).unwrap_or_else(|_| "[]".into())
283 }
284 OutputFormat::JsonPretty => {
285 serde_json::to_string_pretty(findings).unwrap_or_else(|_| "[]".into())
286 }
287 OutputFormat::Csv => {
288 let mut buf = String::from("parameter,vulnerability_type,payload\n");
289 for f in findings {
290 buf.push_str(&format!(
291 "{},{},{}\n",
292 csv_escape(&f.parameter),
293 csv_escape(&f.vulnerability_type),
294 csv_escape(&f.payload),
295 ));
296 }
297 buf
298 }
299 OutputFormat::Markdown => {
300 if findings.is_empty() {
301 return "No SQL injection findings.\n".to_string();
302 }
303 let mut buf = String::from("| Parameter | Type | Payload |\n");
304 buf.push_str("|-----------|------|----------|\n");
305 for f in findings {
306 buf.push_str(&format!(
307 "| `{}` | `{}` | `{}` |\n",
308 markdown_escape(&f.parameter),
309 markdown_escape(&f.vulnerability_type),
310 markdown_escape(&f.payload),
311 ));
312 }
313 buf
314 }
315 OutputFormat::Plain => {
316 if findings.is_empty() {
317 return "No SQL injection findings detected.\n".to_string();
318 }
319 let mut buf = format!("=== {} SQLi Finding(s) ===\n\n", findings.len());
320 for (i, f) in findings.iter().enumerate() {
321 buf.push_str(&format!(
322 "#{} {} on param '{}'\n Payload: {}\n\n",
323 i + 1,
324 f.vulnerability_type,
325 f.parameter,
326 f.payload,
327 ));
328 }
329 buf
330 }
331 }
332}
333
334fn csv_escape(value: &str) -> String {
336 if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
337 format!("\"{}\"", value.replace('"', "\"\""))
338 } else {
339 value.to_string()
340 }
341}
342
343fn markdown_escape(value: &str) -> String {
346 value
347 .replace('\\', "\\\\")
348 .replace('\r', " ")
349 .replace('\n', "<br>")
350 .replace('|', "\\|")
351 .replace('`', "`")
352}
353
354#[derive(Debug, Clone, Serialize, Default)]
375#[non_exhaustive]
376pub struct SqlmapOptions {
377 #[serde(skip_serializing_if = "Option::is_none")]
380 pub url: Option<String>,
381
382 #[serde(rename = "testParameter", skip_serializing_if = "Option::is_none")]
384 pub test_parameter: Option<String>,
385
386 #[serde(skip_serializing_if = "Option::is_none")]
389 pub dbms: Option<String>,
390
391 #[serde(rename = "technique", skip_serializing_if = "Option::is_none")]
393 pub tech: Option<String>,
394
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub level: Option<i32>,
398
399 #[serde(skip_serializing_if = "Option::is_none")]
401 pub risk: Option<i32>,
402
403 #[serde(skip_serializing_if = "Option::is_none")]
405 pub string: Option<String>,
406
407 #[serde(rename = "notString", skip_serializing_if = "Option::is_none")]
409 pub not_string: Option<String>,
410
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub regexp: Option<String>,
414
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub code: Option<i32>,
418
419 #[serde(rename = "textOnly", skip_serializing_if = "Option::is_none")]
421 pub text_only: Option<bool>,
422
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub titles: Option<bool>,
426
427 #[serde(skip_serializing_if = "Option::is_none")]
430 pub cookie: Option<String>,
431
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub headers: Option<String>,
435
436 #[serde(skip_serializing_if = "Option::is_none")]
438 pub method: Option<String>,
439
440 #[serde(skip_serializing_if = "Option::is_none")]
442 pub data: Option<String>,
443
444 #[serde(rename = "randomAgent", skip_serializing_if = "Option::is_none")]
446 pub random_agent: Option<bool>,
447
448 #[serde(skip_serializing_if = "Option::is_none")]
450 pub proxy: Option<String>,
451
452 #[serde(skip_serializing_if = "Option::is_none")]
455 pub prefix: Option<String>,
456
457 #[serde(skip_serializing_if = "Option::is_none")]
459 pub suffix: Option<String>,
460
461 #[serde(skip_serializing_if = "Option::is_none")]
463 pub tamper: Option<String>,
464
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub skip: Option<String>,
468
469 #[serde(rename = "skipStatic", skip_serializing_if = "Option::is_none")]
471 pub skip_static: Option<bool>,
472
473 #[serde(skip_serializing_if = "Option::is_none")]
476 pub threads: Option<i32>,
477
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub verbose: Option<i32>,
481
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub batch: Option<bool>,
485
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub retries: Option<i32>,
489
490 #[serde(rename = "getDbs", skip_serializing_if = "Option::is_none")]
493 pub get_dbs: Option<bool>,
494
495 #[serde(rename = "getTables", skip_serializing_if = "Option::is_none")]
497 pub get_tables: Option<bool>,
498
499 #[serde(rename = "getColumns", skip_serializing_if = "Option::is_none")]
501 pub get_columns: Option<bool>,
502
503 #[serde(rename = "getUsers", skip_serializing_if = "Option::is_none")]
505 pub get_users: Option<bool>,
506
507 #[serde(rename = "getPasswordHashes", skip_serializing_if = "Option::is_none")]
509 pub get_passwords: Option<bool>,
510
511 #[serde(rename = "getPrivileges", skip_serializing_if = "Option::is_none")]
513 pub get_privileges: Option<bool>,
514
515 #[serde(rename = "isDba", skip_serializing_if = "Option::is_none")]
517 pub is_dba: Option<bool>,
518
519 #[serde(rename = "getCurrentUser", skip_serializing_if = "Option::is_none")]
521 pub current_user: Option<bool>,
522
523 #[serde(rename = "getCurrentDb", skip_serializing_if = "Option::is_none")]
525 pub current_db: Option<bool>,
526
527 #[serde(rename = "dumpAll", skip_serializing_if = "Option::is_none")]
529 pub dump_all: Option<bool>,
530
531 #[serde(rename = "dumpTable", skip_serializing_if = "Option::is_none")]
533 pub dump_table: Option<bool>,
534
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub search: Option<bool>,
538
539 #[serde(rename = "osShell", skip_serializing_if = "Option::is_none")]
542 pub os_shell: Option<bool>,
543
544 #[serde(rename = "sqlShell", skip_serializing_if = "Option::is_none")]
546 pub sql_shell: Option<bool>,
547
548 #[serde(rename = "fileRead", skip_serializing_if = "Option::is_none")]
550 pub file_read: Option<String>,
551
552 #[serde(rename = "fileWrite", skip_serializing_if = "Option::is_none")]
554 pub file_write: Option<String>,
555
556 #[serde(rename = "fileDest", skip_serializing_if = "Option::is_none")]
558 pub file_dest: Option<String>,
559
560 #[serde(skip_serializing_if = "Option::is_none")]
563 pub tor: Option<bool>,
564
565 #[serde(rename = "torPort", skip_serializing_if = "Option::is_none")]
567 pub tor_port: Option<i32>,
568
569 #[serde(rename = "torType", skip_serializing_if = "Option::is_none")]
571 pub tor_type: Option<String>,
572
573 #[serde(rename = "crawlDepth", skip_serializing_if = "Option::is_none")]
576 pub crawl_depth: Option<i32>,
577
578 #[serde(skip_serializing_if = "Option::is_none")]
580 pub scope: Option<String>,
581
582 #[serde(skip_serializing_if = "Option::is_none")]
584 pub forms: Option<bool>,
585
586 #[serde(rename = "secondUrl", skip_serializing_if = "Option::is_none")]
589 pub second_url: Option<String>,
590}
591
592#[derive(Debug, Clone, Default)]
597pub struct SqlmapOptionsBuilder {
598 inner: SqlmapOptions,
599}
600
601impl SqlmapOptions {
602 pub fn builder() -> SqlmapOptionsBuilder {
604 SqlmapOptionsBuilder::default()
605 }
606}
607
608macro_rules! builder_method {
610 ($name:ident, $field:ident, String) => {
611 pub fn $name(mut self, value: impl Into<String>) -> Self {
613 self.inner.$field = Some(value.into());
614 self
615 }
616 };
617 ($name:ident, $field:ident, bool) => {
618 pub fn $name(mut self, value: bool) -> Self {
620 self.inner.$field = Some(value);
621 self
622 }
623 };
624 ($name:ident, $field:ident, i32) => {
625 pub fn $name(mut self, value: i32) -> Self {
627 self.inner.$field = Some(value);
628 self
629 }
630 };
631}
632
633impl SqlmapOptionsBuilder {
634 builder_method!(url, url, String);
636 builder_method!(test_parameter, test_parameter, String);
637
638 builder_method!(dbms, dbms, String);
640 builder_method!(tech, tech, String);
641 builder_method!(level, level, i32);
642 builder_method!(risk, risk, i32);
643 builder_method!(string, string, String);
644 builder_method!(not_string, not_string, String);
645 builder_method!(regexp, regexp, String);
646 builder_method!(code, code, i32);
647 builder_method!(text_only, text_only, bool);
648 builder_method!(titles, titles, bool);
649
650 builder_method!(cookie, cookie, String);
652 builder_method!(headers, headers, String);
653 builder_method!(method, method, String);
654 builder_method!(data, data, String);
655 builder_method!(random_agent, random_agent, bool);
656 builder_method!(proxy, proxy, String);
657
658 builder_method!(prefix, prefix, String);
660 builder_method!(suffix, suffix, String);
661 builder_method!(tamper, tamper, String);
662 builder_method!(skip, skip, String);
663 builder_method!(skip_static, skip_static, bool);
664
665 builder_method!(threads, threads, i32);
667 builder_method!(verbose, verbose, i32);
668 builder_method!(batch, batch, bool);
669 builder_method!(retries, retries, i32);
670
671 builder_method!(get_dbs, get_dbs, bool);
673 builder_method!(get_tables, get_tables, bool);
674 builder_method!(get_columns, get_columns, bool);
675 builder_method!(get_users, get_users, bool);
676 builder_method!(get_passwords, get_passwords, bool);
677 builder_method!(get_privileges, get_privileges, bool);
678 builder_method!(is_dba, is_dba, bool);
679 builder_method!(current_user, current_user, bool);
680 builder_method!(current_db, current_db, bool);
681 builder_method!(dump_all, dump_all, bool);
682 builder_method!(dump_table, dump_table, bool);
683 builder_method!(search, search, bool);
684
685 builder_method!(os_shell, os_shell, bool);
687 builder_method!(sql_shell, sql_shell, bool);
688 builder_method!(file_read, file_read, String);
689 builder_method!(file_write, file_write, String);
690 builder_method!(file_dest, file_dest, String);
691
692 builder_method!(tor, tor, bool);
694 builder_method!(tor_port, tor_port, i32);
695 builder_method!(tor_type, tor_type, String);
696
697 builder_method!(crawl_depth, crawl_depth, i32);
699 builder_method!(scope, scope, String);
700 builder_method!(forms, forms, bool);
701
702 builder_method!(second_url, second_url, String);
704
705 pub fn build(self) -> SqlmapOptions {
707 self.inner
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn empty_data_response_gives_no_findings() {
717 let resp = DataResponse {
718 success: true,
719 data: None,
720 error: None,
721 message: None,
722 };
723 assert!(resp.findings().is_empty());
724 }
725
726 #[test]
727 fn type_0_chunks_ignored() {
728 let resp = DataResponse {
729 success: true,
730 data: Some(vec![SqlmapDataChunk {
731 r#type: 0,
732 value: serde_json::json!("log message"),
733 }]),
734 error: None,
735 message: None,
736 };
737 assert!(resp.findings().is_empty());
738 }
739
740 #[test]
741 fn type_1_nested_techniques_expanded() {
742 let resp = DataResponse {
743 success: true,
744 data: Some(vec![SqlmapDataChunk {
745 r#type: 1,
746 value: serde_json::json!([{
747 "place": "GET",
748 "parameter": "id",
749 "data": [
750 {
751 "title": "AND boolean-based blind - WHERE or HAVING clause",
752 "payload": "id=1 AND 8888=8888"
753 },
754 {
755 "technique": "time-based blind",
756 "payload": "id=1 AND SLEEP(5)"
757 }
758 ]
759 }]),
760 }]),
761 error: None,
762 message: None,
763 };
764 let findings = resp.findings();
765 assert_eq!(findings.len(), 2);
766 assert_eq!(findings[0].parameter, "id");
767 assert_eq!(
768 findings[0].vulnerability_type,
769 "AND boolean-based blind - WHERE or HAVING clause"
770 );
771 assert_eq!(findings[0].payload, "id=1 AND 8888=8888");
772 assert_eq!(findings[1].vulnerability_type, "time-based blind");
773 assert_eq!(findings[1].payload, "id=1 AND SLEEP(5)");
774 }
775
776 #[test]
777 fn type_1_chunk_parsed_as_finding() {
778 let resp = DataResponse {
779 success: true,
780 data: Some(vec![SqlmapDataChunk {
781 r#type: 1,
782 value: serde_json::json!([{
783 "parameter": "id",
784 "type": "boolean-based blind",
785 "payload": "id=1 AND 1=1"
786 }]),
787 }]),
788 error: None,
789 message: None,
790 };
791 let findings = resp.findings();
792 assert_eq!(findings.len(), 1);
793 assert_eq!(findings[0].parameter, "id");
794 assert_eq!(findings[0].vulnerability_type, "boolean-based blind");
795 }
796
797 #[test]
798 fn builder_pattern_serializes_correctly() {
799 let opts = SqlmapOptions::builder()
800 .url("http://test.com?id=1")
801 .level(3)
802 .risk(2)
803 .batch(true)
804 .threads(4)
805 .tamper("space2comment")
806 .build();
807 let json = serde_json::to_string(&opts).expect("serialize");
808 assert!(json.contains("http://test.com"));
809 assert!(json.contains("\"level\":3"));
810 assert!(json.contains("\"threads\":4"));
811 assert!(json.contains("space2comment"));
812 assert!(!json.contains("dbms"));
814 }
815
816 #[test]
817 fn tech_serializes_as_technique() {
818 let opts = SqlmapOptions::builder().tech("BEUSTQ").build();
819 let json = serde_json::to_string(&opts).expect("serialize");
820 assert!(json.contains("\"technique\":\"BEUSTQ\""));
821 assert!(!json.contains("\"tech\""));
822 }
823
824 #[test]
825 fn type_1_chunk_edge_cases() {
826 let resp = DataResponse {
827 success: true,
828 data: Some(vec![SqlmapDataChunk {
829 r#type: 1,
830 value: serde_json::json!([
831 { "parameter": "username" },
832 "string_instead_of_object_should_be_ignored",
833 { "type": "error-based" },
834 {
835 "parameter": "id",
836 "type": "boolean-based blind",
837 "payload": "id=1 AND 1=1"
838 }
839 ]),
840 }]),
841 error: None,
842 message: None,
843 };
844 let findings = resp.findings();
845 assert_eq!(findings.len(), 1);
846 assert_eq!(findings[0].parameter, "id");
847 assert_eq!(findings[0].vulnerability_type, "boolean-based blind");
848 assert_eq!(findings[0].payload, "id=1 AND 1=1");
849 }
850
851 #[test]
852 fn new_options_fields_serialize() {
853 let opts = SqlmapOptions::builder()
854 .tor(true)
855 .tor_port(9050)
856 .tor_type("SOCKS5")
857 .crawl_depth(3)
858 .second_url("http://verify.com")
859 .tamper("between,randomcase")
860 .retries(5)
861 .dump_all(true)
862 .file_read("/etc/passwd")
863 .build();
864 let json = serde_json::to_string(&opts).expect("serialize");
865 assert!(json.contains("\"tor\":true"));
866 assert!(json.contains("\"torPort\":9050"));
867 assert!(json.contains("\"crawlDepth\":3"));
868 assert!(json.contains("\"secondUrl\""));
869 assert!(json.contains("\"fileRead\""));
870 assert!(json.contains("\"dumpAll\":true"));
871 }
872
873 #[test]
874 fn finding_display() {
875 let finding = SqlmapFinding {
876 parameter: "id".into(),
877 vulnerability_type: "boolean-based blind".into(),
878 payload: "id=1 AND 1=1".into(),
879 details: serde_json::json!({}),
880 };
881 let display = format!("{finding}");
882 assert!(display.contains("boolean-based blind"));
883 assert!(display.contains("id"));
884 }
885
886 #[test]
887 fn format_csv_output() {
888 let findings = vec![SqlmapFinding {
889 parameter: "id".into(),
890 vulnerability_type: "error-based".into(),
891 payload: "' OR 1=1--".into(),
892 details: serde_json::json!({}),
893 }];
894 let csv = format_findings(&findings, OutputFormat::Csv);
895 assert!(csv.starts_with("parameter,vulnerability_type,payload\n"));
896 assert!(csv.contains("error-based"));
897 }
898
899 #[test]
900 fn format_plain_empty() {
901 let plain = format_findings(&[], OutputFormat::Plain);
902 assert_eq!(plain, "No SQL injection findings detected.\n");
903 }
904
905 #[test]
906 fn format_markdown_output() {
907 let findings = vec![SqlmapFinding {
908 parameter: "id".into(),
909 vulnerability_type: "UNION query".into(),
910 payload: "id=1 UNION SELECT 1,2--".into(),
911 details: serde_json::json!({}),
912 }];
913 let md = format_findings(&findings, OutputFormat::Markdown);
914 assert!(md.contains("| Parameter |"));
915 assert!(md.contains("UNION query"));
916 }
917}