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