1use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use tempfile::TempDir;
31
32use crate::command::BenchCommand;
33use crate::error::{BenchError, Result};
34use crate::executor::{K6Executor, K6Results};
35use crate::ssrf::{validate_target_url, Policy as SsrfPolicy};
36
37fn resolve_ssrf_policy() -> SsrfPolicy {
44 match std::env::var("MOCKFORGE_SSRF_ALLOW_LOOPBACK").as_deref() {
45 Ok("1") | Ok("true") => SsrfPolicy::for_test(),
46 _ => SsrfPolicy::strict(),
47 }
48}
49
50async fn enforce_ssrf(target_url: &str) -> Result<()> {
54 let policy = resolve_ssrf_policy();
55 validate_target_url(target_url, policy)
56 .await
57 .map_err(|e| BenchError::Other(format!("SSRF guard rejected target: {}", e)))
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub enum SpecFormat {
63 Json,
65 Yaml,
67 #[default]
69 Auto,
70}
71
72impl SpecFormat {
73 fn extension(self, bytes: &[u8]) -> &'static str {
74 match self {
75 SpecFormat::Json => "json",
76 SpecFormat::Yaml => "yaml",
77 SpecFormat::Auto => match bytes.iter().find(|b| !b.is_ascii_whitespace()) {
78 Some(b'{') | Some(b'[') => "json",
79 _ => "yaml",
80 },
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
91pub struct CloudBenchInputs {
92 pub spec_bytes: Vec<u8>,
93 pub spec_format: SpecFormat,
94 pub target_url: String,
95 pub base_path: Option<String>,
96 pub duration: String,
97 pub vus: u32,
98 pub scenario: String,
99 pub operations: Option<String>,
100 pub exclude_operations: Option<String>,
101 pub auth: Option<String>,
102 pub headers: Option<String>,
105 pub threshold_percentile: String,
106 pub threshold_ms: u64,
107 pub max_error_rate: f64,
108 pub skip_tls_verify: bool,
109 pub chunked_request_bodies: bool,
110}
111
112impl Default for CloudBenchInputs {
113 fn default() -> Self {
114 Self {
115 spec_bytes: Vec::new(),
116 spec_format: SpecFormat::Auto,
117 target_url: String::new(),
118 base_path: None,
119 duration: "30s".to_string(),
120 vus: 10,
121 scenario: "constant".to_string(),
122 operations: None,
123 exclude_operations: None,
124 auth: None,
125 headers: None,
126 threshold_percentile: "p(95)".to_string(),
127 threshold_ms: 1000,
128 max_error_rate: 0.01,
129 skip_tls_verify: false,
130 chunked_request_bodies: false,
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
142pub struct CloudConformanceInputs {
143 pub spec_bytes: Option<Vec<u8>>,
144 pub spec_format: SpecFormat,
145 pub target_url: String,
146 pub base_path: Option<String>,
147 pub api_key: Option<String>,
148 pub basic_auth: Option<String>,
150 pub categories: Option<String>,
152 pub headers: Vec<String>,
154 pub all_operations: bool,
155 pub request_delay_ms: u64,
156 pub use_k6: bool,
159 pub skip_tls_verify: bool,
160 pub report_format: String,
162 pub export_requests: bool,
163 pub validate_requests: bool,
164}
165
166impl Default for CloudConformanceInputs {
167 fn default() -> Self {
168 Self {
169 spec_bytes: None,
170 spec_format: SpecFormat::Auto,
171 target_url: String::new(),
172 base_path: None,
173 api_key: None,
174 basic_auth: None,
175 categories: None,
176 headers: Vec::new(),
177 all_operations: false,
178 request_delay_ms: 0,
179 use_k6: false,
180 skip_tls_verify: false,
181 report_format: "json".to_string(),
182 export_requests: false,
183 validate_requests: false,
184 }
185 }
186}
187
188#[derive(Debug, Default, Clone)]
194pub struct CloudRunArtifacts {
195 pub k6_results: Option<K6Results>,
196 pub files: HashMap<String, Vec<u8>>,
197}
198
199impl CloudRunArtifacts {
200 pub fn get(&self, name: &str) -> Option<&[u8]> {
201 self.files.get(name).map(Vec::as_slice)
202 }
203
204 pub fn get_string(&self, name: &str) -> Option<String> {
205 self.get(name).map(|b| String::from_utf8_lossy(b).into_owned())
206 }
207
208 pub fn get_json(&self, name: &str) -> Option<serde_json::Value> {
209 self.get(name).and_then(|b| serde_json::from_slice(b).ok())
210 }
211}
212
213pub async fn run_bench(inputs: CloudBenchInputs) -> Result<CloudRunArtifacts> {
219 if inputs.target_url.trim().is_empty() {
220 return Err(BenchError::Other("target_url is required".to_string()));
221 }
222 if inputs.spec_bytes.is_empty() {
223 return Err(BenchError::Other("spec_bytes is required for bench runs".to_string()));
224 }
225 if !K6Executor::is_k6_installed() {
226 return Err(BenchError::K6NotFound);
227 }
228 enforce_ssrf(&inputs.target_url).await?;
229
230 let workdir = TempDir::new()
231 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
232 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
233 let output_dir = workdir.path().join("output");
234 std::fs::create_dir_all(&output_dir)
235 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
236
237 let cmd = BenchCommand {
238 spec: vec![spec_path],
239 target: inputs.target_url,
240 base_path: inputs.base_path,
241 duration: inputs.duration,
242 vus: inputs.vus,
243 scenario: inputs.scenario,
244 operations: inputs.operations,
245 exclude_operations: inputs.exclude_operations,
246 auth: inputs.auth,
247 headers: inputs.headers,
248 threshold_percentile: inputs.threshold_percentile,
249 threshold_ms: inputs.threshold_ms,
250 max_error_rate: inputs.max_error_rate,
251 skip_tls_verify: inputs.skip_tls_verify,
252 chunked_request_bodies: inputs.chunked_request_bodies,
253 ..default_bench_command(&output_dir)
254 };
255
256 cmd.execute().await?;
257 read_artifacts(&output_dir)
258}
259
260pub async fn run_conformance(inputs: CloudConformanceInputs) -> Result<CloudRunArtifacts> {
266 if inputs.target_url.trim().is_empty() {
267 return Err(BenchError::Other("target_url is required".to_string()));
268 }
269 if inputs.use_k6 && !K6Executor::is_k6_installed() {
270 return Err(BenchError::K6NotFound);
271 }
272 enforce_ssrf(&inputs.target_url).await?;
273
274 let workdir = TempDir::new()
275 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
276 let output_dir = workdir.path().join("output");
277 std::fs::create_dir_all(&output_dir)
278 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
279
280 let spec_paths = if let Some(bytes) = &inputs.spec_bytes {
281 vec![write_spec(workdir.path(), bytes, inputs.spec_format)?]
282 } else {
283 Vec::new()
284 };
285
286 let report_path = output_dir.join("conformance-report.json");
287 let cmd = BenchCommand {
288 spec: spec_paths,
289 target: inputs.target_url,
290 base_path: inputs.base_path,
291 skip_tls_verify: inputs.skip_tls_verify,
292 conformance: true,
293 conformance_api_key: inputs.api_key,
294 conformance_basic_auth: inputs.basic_auth,
295 conformance_report: report_path,
296 conformance_categories: inputs.categories,
297 conformance_report_format: inputs.report_format,
298 conformance_headers: inputs.headers,
299 conformance_all_operations: inputs.all_operations,
300 conformance_delay_ms: inputs.request_delay_ms,
301 use_k6: inputs.use_k6,
302 export_requests: inputs.export_requests,
303 validate_requests: inputs.validate_requests,
304 ..default_bench_command(&output_dir)
305 };
306
307 cmd.execute().await?;
308 read_artifacts(&output_dir)
309}
310
311#[derive(Debug, Clone)]
317pub struct CloudOwaspInputs {
318 pub spec_bytes: Vec<u8>,
319 pub spec_format: SpecFormat,
320 pub target_url: String,
321 pub base_path: Option<String>,
322 pub categories: Option<String>,
325 pub auth_header: String,
327 pub auth_token: Option<String>,
330 pub admin_paths: Vec<String>,
334 pub id_fields: Option<String>,
337 pub report_format: String,
339 pub iterations: u32,
341 pub vus: u32,
342 pub skip_tls_verify: bool,
343 pub headers: Option<String>,
345}
346
347impl Default for CloudOwaspInputs {
348 fn default() -> Self {
349 Self {
350 spec_bytes: Vec::new(),
351 spec_format: SpecFormat::Auto,
352 target_url: String::new(),
353 base_path: None,
354 categories: None,
355 auth_header: "Authorization".to_string(),
356 auth_token: None,
357 admin_paths: Vec::new(),
358 id_fields: None,
359 report_format: "json".to_string(),
360 iterations: 1,
361 vus: 10,
362 skip_tls_verify: false,
363 headers: None,
364 }
365 }
366}
367
368#[derive(Debug, Clone)]
376pub struct CloudSecurityInputs {
377 pub spec_bytes: Vec<u8>,
378 pub spec_format: SpecFormat,
379 pub target_url: String,
380 pub base_path: Option<String>,
381 pub duration: String,
382 pub vus: u32,
383 pub scenario: String,
384 pub categories: Option<String>,
386 pub target_fields: Option<String>,
388 pub auth: Option<String>,
389 pub headers: Option<String>,
390 pub skip_tls_verify: bool,
391}
392
393impl Default for CloudSecurityInputs {
394 fn default() -> Self {
395 Self {
396 spec_bytes: Vec::new(),
397 spec_format: SpecFormat::Auto,
398 target_url: String::new(),
399 base_path: None,
400 duration: "30s".to_string(),
401 vus: 10,
402 scenario: "constant".to_string(),
403 categories: None,
404 target_fields: None,
405 auth: None,
406 headers: None,
407 skip_tls_verify: false,
408 }
409 }
410}
411
412#[derive(Debug, Clone)]
419pub struct CloudWafBenchInputs {
420 pub spec_bytes: Vec<u8>,
421 pub spec_format: SpecFormat,
422 pub target_url: String,
423 pub base_path: Option<String>,
424 pub duration: String,
425 pub vus: u32,
426 pub scenario: String,
427 pub rules_dir: String,
429 pub cycle_all: bool,
432 pub auth: Option<String>,
433 pub headers: Option<String>,
434 pub skip_tls_verify: bool,
435}
436
437impl Default for CloudWafBenchInputs {
438 fn default() -> Self {
439 Self {
440 spec_bytes: Vec::new(),
441 spec_format: SpecFormat::Auto,
442 target_url: String::new(),
443 base_path: None,
444 duration: "30s".to_string(),
445 vus: 10,
446 scenario: "constant".to_string(),
447 rules_dir: String::new(),
448 cycle_all: false,
449 auth: None,
450 headers: None,
451 skip_tls_verify: false,
452 }
453 }
454}
455
456#[derive(Debug, Clone)]
462pub struct CloudCrudFlowInputs {
463 pub spec_bytes: Vec<u8>,
464 pub spec_format: SpecFormat,
465 pub target_url: String,
466 pub base_path: Option<String>,
467 pub duration: String,
468 pub vus: u32,
469 pub scenario: String,
470 pub flow_config_yaml: Option<String>,
473 pub extract_fields: Option<String>,
476 pub auth: Option<String>,
477 pub headers: Option<String>,
478 pub skip_tls_verify: bool,
479}
480
481impl Default for CloudCrudFlowInputs {
482 fn default() -> Self {
483 Self {
484 spec_bytes: Vec::new(),
485 spec_format: SpecFormat::Auto,
486 target_url: String::new(),
487 base_path: None,
488 duration: "30s".to_string(),
489 vus: 10,
490 scenario: "constant".to_string(),
491 flow_config_yaml: None,
492 extract_fields: None,
493 auth: None,
494 headers: None,
495 skip_tls_verify: false,
496 }
497 }
498}
499
500pub async fn run_owasp(inputs: CloudOwaspInputs) -> Result<CloudRunArtifacts> {
504 if inputs.target_url.trim().is_empty() {
505 return Err(BenchError::Other("target_url is required".to_string()));
506 }
507 if inputs.spec_bytes.is_empty() {
508 return Err(BenchError::Other("spec_bytes is required for OWASP runs".to_string()));
509 }
510 if !K6Executor::is_k6_installed() {
511 return Err(BenchError::K6NotFound);
512 }
513 enforce_ssrf(&inputs.target_url).await?;
514
515 let workdir = TempDir::new()
516 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
517 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
518 let output_dir = workdir.path().join("output");
519 std::fs::create_dir_all(&output_dir)
520 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
521
522 let admin_paths_path = if !inputs.admin_paths.is_empty() {
523 let p = workdir.path().join("admin-paths.txt");
524 std::fs::write(&p, inputs.admin_paths.join("\n"))
525 .map_err(|e| BenchError::Other(format!("Failed to write admin paths file: {}", e)))?;
526 Some(p)
527 } else {
528 None
529 };
530
531 let report_path = output_dir.join("owasp-report.json");
532 let cmd = BenchCommand {
533 spec: vec![spec_path],
534 target: inputs.target_url,
535 base_path: inputs.base_path,
536 vus: inputs.vus,
537 skip_tls_verify: inputs.skip_tls_verify,
538 headers: inputs.headers,
539 owasp_api_top10: true,
540 owasp_categories: inputs.categories,
541 owasp_auth_header: inputs.auth_header,
542 owasp_auth_token: inputs.auth_token,
543 owasp_admin_paths: admin_paths_path,
544 owasp_id_fields: inputs.id_fields,
545 owasp_report: Some(report_path),
546 owasp_report_format: inputs.report_format,
547 owasp_iterations: inputs.iterations,
548 ..default_bench_command(&output_dir)
549 };
550
551 cmd.execute().await?;
552 read_artifacts(&output_dir)
553}
554
555pub async fn run_security(inputs: CloudSecurityInputs) -> Result<CloudRunArtifacts> {
559 if inputs.target_url.trim().is_empty() {
560 return Err(BenchError::Other("target_url is required".to_string()));
561 }
562 if inputs.spec_bytes.is_empty() {
563 return Err(BenchError::Other("spec_bytes is required for security runs".to_string()));
564 }
565 if !K6Executor::is_k6_installed() {
566 return Err(BenchError::K6NotFound);
567 }
568 enforce_ssrf(&inputs.target_url).await?;
569
570 let workdir = TempDir::new()
571 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
572 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
573 let output_dir = workdir.path().join("output");
574 std::fs::create_dir_all(&output_dir)
575 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
576
577 let cmd = BenchCommand {
578 spec: vec![spec_path],
579 target: inputs.target_url,
580 base_path: inputs.base_path,
581 duration: inputs.duration,
582 vus: inputs.vus,
583 scenario: inputs.scenario,
584 auth: inputs.auth,
585 headers: inputs.headers,
586 skip_tls_verify: inputs.skip_tls_verify,
587 security_test: true,
588 security_categories: inputs.categories,
589 security_target_fields: inputs.target_fields,
590 ..default_bench_command(&output_dir)
591 };
592
593 cmd.execute().await?;
594 read_artifacts(&output_dir)
595}
596
597pub async fn run_wafbench(inputs: CloudWafBenchInputs) -> Result<CloudRunArtifacts> {
602 if inputs.target_url.trim().is_empty() {
603 return Err(BenchError::Other("target_url is required".to_string()));
604 }
605 if inputs.spec_bytes.is_empty() {
606 return Err(BenchError::Other("spec_bytes is required for WAFBench runs".to_string()));
607 }
608 if inputs.rules_dir.trim().is_empty() {
609 return Err(BenchError::Other(
610 "rules_dir is required for WAFBench runs (point at the bundled CRS install)"
611 .to_string(),
612 ));
613 }
614 if !K6Executor::is_k6_installed() {
615 return Err(BenchError::K6NotFound);
616 }
617 enforce_ssrf(&inputs.target_url).await?;
618
619 let workdir = TempDir::new()
620 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
621 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
622 let output_dir = workdir.path().join("output");
623 std::fs::create_dir_all(&output_dir)
624 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
625
626 let cmd = BenchCommand {
627 spec: vec![spec_path],
628 target: inputs.target_url,
629 base_path: inputs.base_path,
630 duration: inputs.duration,
631 vus: inputs.vus,
632 scenario: inputs.scenario,
633 auth: inputs.auth,
634 headers: inputs.headers,
635 skip_tls_verify: inputs.skip_tls_verify,
636 wafbench_dir: Some(inputs.rules_dir),
637 wafbench_cycle_all: inputs.cycle_all,
638 ..default_bench_command(&output_dir)
639 };
640
641 cmd.execute().await?;
642 read_artifacts(&output_dir)
643}
644
645pub async fn run_crud_flow(inputs: CloudCrudFlowInputs) -> Result<CloudRunArtifacts> {
649 if inputs.target_url.trim().is_empty() {
650 return Err(BenchError::Other("target_url is required".to_string()));
651 }
652 if inputs.spec_bytes.is_empty() {
653 return Err(BenchError::Other("spec_bytes is required for CRUD flow runs".to_string()));
654 }
655 if !K6Executor::is_k6_installed() {
656 return Err(BenchError::K6NotFound);
657 }
658 enforce_ssrf(&inputs.target_url).await?;
659
660 let workdir = TempDir::new()
661 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
662 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
663 let output_dir = workdir.path().join("output");
664 std::fs::create_dir_all(&output_dir)
665 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
666
667 let flow_config_path = if let Some(yaml) = &inputs.flow_config_yaml {
668 let p = workdir.path().join("flow-config.yaml");
669 std::fs::write(&p, yaml)
670 .map_err(|e| BenchError::Other(format!("Failed to write flow config: {}", e)))?;
671 Some(p)
672 } else {
673 None
674 };
675
676 let cmd = BenchCommand {
677 spec: vec![spec_path],
678 target: inputs.target_url,
679 base_path: inputs.base_path,
680 duration: inputs.duration,
681 vus: inputs.vus,
682 scenario: inputs.scenario,
683 auth: inputs.auth,
684 headers: inputs.headers,
685 skip_tls_verify: inputs.skip_tls_verify,
686 crud_flow: true,
687 flow_config: flow_config_path,
688 extract_fields: inputs.extract_fields,
689 ..default_bench_command(&output_dir)
690 };
691
692 cmd.execute().await?;
693 read_artifacts(&output_dir)
694}
695
696fn default_bench_command(output_dir: &Path) -> BenchCommand {
702 BenchCommand {
703 spec: Vec::new(),
704 spec_dir: None,
705 merge_conflicts: "error".to_string(),
706 spec_mode: "merge".to_string(),
707 dependency_config: None,
708 target: String::new(),
709 base_path: None,
710 duration: "30s".to_string(),
711 vus: 10,
712 target_rps: None,
713 no_keep_alive: false,
714 scenario: "constant".to_string(),
715 operations: None,
716 exclude_operations: None,
717 auth: None,
718 headers: None,
719 output: output_dir.to_path_buf(),
720 generate_only: false,
721 script_output: None,
722 threshold_percentile: "p(95)".to_string(),
723 threshold_ms: 1000,
724 max_error_rate: 0.01,
725 verbose: false,
726 skip_tls_verify: false,
727 chunked_request_bodies: false,
728 targets_file: None,
729 max_concurrency: None,
730 results_format: "aggregated".to_string(),
731 params_file: None,
732 crud_flow: false,
733 flow_config: None,
734 extract_fields: None,
735 parallel_create: None,
736 data_file: None,
737 data_distribution: "unique-per-vu".to_string(),
738 data_mappings: None,
739 per_uri_control: false,
740 error_rate: None,
741 error_types: None,
742 security_test: false,
743 security_payloads: None,
744 security_categories: None,
745 security_target_fields: None,
746 wafbench_dir: None,
747 wafbench_cycle_all: false,
748 conformance: false,
749 conformance_api_key: None,
750 conformance_basic_auth: None,
751 conformance_report: output_dir.join("conformance-report.json"),
752 conformance_categories: None,
753 conformance_report_format: "json".to_string(),
754 conformance_headers: Vec::new(),
755 conformance_all_operations: false,
756 conformance_custom: None,
757 conformance_delay_ms: 0,
758 use_k6: false,
759 conformance_custom_filter: None,
760 export_requests: false,
761 validate_requests: false,
762 owasp_api_top10: false,
763 owasp_categories: None,
764 owasp_auth_header: "Authorization".to_string(),
765 owasp_auth_token: None,
766 owasp_admin_paths: None,
767 owasp_id_fields: None,
768 owasp_report: None,
769 owasp_report_format: "json".to_string(),
770 owasp_iterations: 1,
771 }
772}
773
774fn write_spec(dir: &Path, bytes: &[u8], format: SpecFormat) -> Result<PathBuf> {
775 let filename = format!("spec.{}", format.extension(bytes));
776 let path = dir.join(filename);
777 std::fs::write(&path, bytes)
778 .map_err(|e| BenchError::Other(format!("Failed to write spec to tempdir: {}", e)))?;
779 Ok(path)
780}
781
782fn read_artifacts(output_dir: &Path) -> Result<CloudRunArtifacts> {
783 let mut files = HashMap::new();
784 if output_dir.exists() {
785 let entries = std::fs::read_dir(output_dir)
786 .map_err(|e| BenchError::Other(format!("Failed to read output dir: {}", e)))?;
787 for entry in entries {
788 let entry =
789 entry.map_err(|e| BenchError::Other(format!("Failed to read entry: {}", e)))?;
790 let metadata = entry
791 .metadata()
792 .map_err(|e| BenchError::Other(format!("Failed to stat entry: {}", e)))?;
793 if !metadata.is_file() {
794 continue;
795 }
796 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
797 continue;
798 };
799 let bytes = std::fs::read(entry.path()).map_err(|e| {
800 BenchError::Other(format!("Failed to read artifact {}: {}", name, e))
801 })?;
802 files.insert(name, bytes);
803 }
804 }
805
806 let k6_results = files.get("summary.json").and_then(|bytes| parse_k6_summary(bytes).ok());
807
808 Ok(CloudRunArtifacts { k6_results, files })
809}
810
811fn parse_k6_summary(bytes: &[u8]) -> Result<K6Results> {
812 let json: serde_json::Value =
813 serde_json::from_slice(bytes).map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
814 let duration_values = &json["metrics"]["http_req_duration"]["values"];
815 let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
816 let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
817 let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
818 let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
819 let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
820 Ok(K6Results {
821 total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
822 failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
825 .as_u64()
826 .unwrap_or(0),
827 avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
828 p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
829 p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
830 rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
831 vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
832 min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
833 max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
834 med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
835 p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
836 server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
837 server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
838 server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
839 server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
840 server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
841 server_reported_faults: server_fault.as_u64().unwrap_or(0),
842 tcp_connect_samples: tcp_connecting["count"].as_u64().unwrap_or(0),
843 tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
844 tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
845 tls_handshake_samples: tls_handshake["count"].as_u64().unwrap_or(0),
846 tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
847 tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
848 })
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854
855 #[test]
856 fn spec_format_extension_for_json_bytes() {
857 assert_eq!(SpecFormat::Auto.extension(b" {\"openapi\":\"3.0.0\"}"), "json");
858 assert_eq!(SpecFormat::Auto.extension(b"openapi: 3.0.0\n"), "yaml");
859 assert_eq!(SpecFormat::Json.extension(b"openapi: 3.0.0"), "json");
860 assert_eq!(SpecFormat::Yaml.extension(b"{}"), "yaml");
861 }
862
863 #[test]
864 fn write_spec_round_trips_bytes() {
865 let dir = TempDir::new().unwrap();
866 let path = write_spec(dir.path(), b"openapi: 3.0.0\n", SpecFormat::Yaml).unwrap();
867 assert!(path.ends_with("spec.yaml"));
868 let read_back = std::fs::read(&path).unwrap();
869 assert_eq!(read_back, b"openapi: 3.0.0\n");
870 }
871
872 #[test]
873 fn read_artifacts_collects_top_level_files_only() {
874 let dir = TempDir::new().unwrap();
875 let out = dir.path();
876 std::fs::write(out.join("summary.json"), br#"{"metrics":{}}"#).unwrap();
877 std::fs::write(out.join("k6-output.log"), b"hello").unwrap();
878 std::fs::create_dir(out.join("nested")).unwrap();
880 std::fs::write(out.join("nested").join("ignored.txt"), b"nope").unwrap();
881
882 let artifacts = read_artifacts(out).unwrap();
883 assert_eq!(artifacts.files.len(), 2);
884 assert!(artifacts.files.contains_key("summary.json"));
885 assert!(artifacts.files.contains_key("k6-output.log"));
886 assert!(!artifacts.files.contains_key("ignored.txt"));
887 }
888
889 #[test]
890 fn parse_k6_summary_handles_minimal_input() {
891 let bytes = br#"{"metrics":{}}"#;
892 let r = parse_k6_summary(bytes).unwrap();
893 assert_eq!(r.total_requests, 0);
894 assert_eq!(r.failed_requests, 0);
895 assert_eq!(r.error_rate(), 0.0);
896 }
897
898 #[test]
899 fn parse_k6_summary_extracts_values() {
900 let bytes = br#"{
901 "metrics": {
902 "http_reqs": {"values": {"count": 100, "rate": 33.5}},
903 "http_req_failed": {"values": {"passes": 4}},
904 "http_req_duration": {"values": {
905 "avg": 12.3, "med": 10.0, "min": 1.0, "max": 50.0,
906 "p(90)": 20.0, "p(95)": 25.0, "p(99)": 40.0
907 }},
908 "vus_max": {"values": {"value": 10}}
909 }
910 }"#;
911 let r = parse_k6_summary(bytes).unwrap();
912 assert_eq!(r.total_requests, 100);
913 assert_eq!(r.failed_requests, 4);
914 assert_eq!(r.rps, 33.5);
915 assert_eq!(r.p95_duration_ms, 25.0);
916 assert_eq!(r.vus_max, 10);
917 }
918
919 #[test]
920 fn cloud_run_artifacts_get_helpers() {
921 let mut a = CloudRunArtifacts::default();
922 a.files.insert("hello.txt".to_string(), b"world".to_vec());
923 a.files.insert("payload.json".to_string(), br#"{"x":1}"#.to_vec());
924
925 assert_eq!(a.get("hello.txt").unwrap(), b"world");
926 assert_eq!(a.get_string("hello.txt").unwrap(), "world");
927 assert_eq!(a.get_json("payload.json").unwrap()["x"], 1);
928 assert!(a.get("missing").is_none());
929 }
930
931 #[tokio::test]
932 async fn run_bench_rejects_empty_target() {
933 let inputs = CloudBenchInputs {
934 spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
935 ..Default::default()
936 };
937 let err = run_bench(inputs).await.unwrap_err();
938 assert!(matches!(err, BenchError::Other(_)));
939 }
940
941 #[tokio::test]
942 async fn run_bench_rejects_empty_spec() {
943 let inputs = CloudBenchInputs {
944 target_url: "https://example.com".to_string(),
945 ..Default::default()
946 };
947 let err = run_bench(inputs).await.unwrap_err();
948 assert!(matches!(err, BenchError::Other(_)));
949 }
950
951 #[tokio::test]
952 async fn run_conformance_rejects_empty_target() {
953 let inputs = CloudConformanceInputs::default();
954 let err = run_conformance(inputs).await.unwrap_err();
955 assert!(matches!(err, BenchError::Other(_)));
956 }
957
958 #[tokio::test]
959 async fn run_owasp_rejects_missing_inputs() {
960 let no_target = run_owasp(CloudOwaspInputs {
961 spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
962 ..Default::default()
963 })
964 .await
965 .unwrap_err();
966 assert!(matches!(no_target, BenchError::Other(_)));
967
968 let no_spec = run_owasp(CloudOwaspInputs {
969 target_url: "https://example.com".to_string(),
970 ..Default::default()
971 })
972 .await
973 .unwrap_err();
974 assert!(matches!(no_spec, BenchError::Other(_)));
975 }
976
977 #[tokio::test]
978 async fn run_security_rejects_missing_inputs() {
979 let err = run_security(CloudSecurityInputs::default()).await.unwrap_err();
980 assert!(matches!(err, BenchError::Other(_)));
981 }
982
983 #[tokio::test]
984 async fn run_wafbench_rejects_missing_rules_dir() {
985 let err = run_wafbench(CloudWafBenchInputs {
986 spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
987 target_url: "https://example.com".to_string(),
988 ..Default::default()
989 })
990 .await
991 .unwrap_err();
992 let BenchError::Other(msg) = err else {
993 panic!("expected BenchError::Other");
994 };
995 assert!(msg.contains("rules_dir"), "got: {msg}");
996 }
997
998 #[tokio::test]
999 async fn run_crud_flow_rejects_missing_inputs() {
1000 let err = run_crud_flow(CloudCrudFlowInputs::default()).await.unwrap_err();
1001 assert!(matches!(err, BenchError::Other(_)));
1002 }
1003}