1use crate::crud_flow::{CrudFlowConfig, CrudFlowDetector};
4use crate::data_driven::{DataDistribution, DataDrivenConfig, DataDrivenGenerator, DataMapping};
5use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
6use crate::error::{BenchError, Result};
7use crate::executor::K6Executor;
8use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator};
9use crate::k6_gen::{K6Config, K6ScriptGenerator};
10use crate::mock_integration::{
11 MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
12};
13use crate::owasp_api::{OwaspApiConfig, OwaspApiGenerator, OwaspCategory, ReportFormat};
14use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
15use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
16use crate::param_overrides::ParameterOverrides;
17use crate::reporter::TerminalReporter;
18use crate::request_gen::RequestGenerator;
19use crate::scenarios::LoadScenario;
20use crate::security_payloads::{
21 SecurityCategory, SecurityPayload, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
22};
23use crate::spec_dependencies::{
24 topological_sort, DependencyDetector, ExtractedValues, SpecDependencyConfig,
25};
26use crate::spec_parser::SpecParser;
27use crate::target_parser::parse_targets_file;
28use crate::wafbench::WafBenchLoader;
29use mockforge_openapi::multi_spec::{
30 load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36
37pub fn parse_header_string(inputs: &[String]) -> Result<HashMap<String, String>> {
45 let mut headers = HashMap::new();
46
47 for pair in inputs {
48 let pair = pair.trim();
49 if pair.is_empty() {
50 continue;
51 }
52 let parts: Vec<&str> = pair.splitn(2, ':').collect();
53 if parts.len() != 2 {
54 return Err(BenchError::Other(format!(
55 "Invalid header format: '{}'. Expected 'Key:Value'",
56 pair
57 )));
58 }
59 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
60 }
61
62 Ok(headers)
63}
64
65const CONFORMANCE_REPLACES_LOAD_ADVISORY: &str =
85 "Conformance mode REPLACES the load run: 1 VU, 1 iteration per endpoint. \
86 --vus, --rps and -d are ignored. Run bench a second time without \
87 --conformance if you also want a load test.";
88
89pub struct BenchCommand {
91 pub spec: Vec<PathBuf>,
93 pub spec_dir: Option<PathBuf>,
95 pub merge_conflicts: String,
97 pub spec_mode: String,
99 pub dependency_config: Option<PathBuf>,
101 pub target: String,
102 pub base_path: Option<String>,
105 pub duration: String,
106 pub vus: u32,
107 pub target_rps: Option<u32>,
113 pub no_keep_alive: bool,
118 pub scenario: String,
119 pub operations: Option<String>,
120 pub exclude_operations: Option<String>,
124 pub auth: Option<String>,
125 pub headers: Vec<String>,
128 pub output: PathBuf,
129 pub generate_only: bool,
130 pub script_output: Option<PathBuf>,
131 pub threshold_percentile: String,
132 pub threshold_ms: u64,
133 pub max_error_rate: f64,
134 pub abort_on_error: bool,
139 pub abort_on_error_rate: f64,
143 pub verbose: bool,
144 pub skip_tls_verify: bool,
145 pub chunked_request_bodies: bool,
150 pub targets_file: Option<PathBuf>,
152 pub max_concurrency: Option<u32>,
154 pub results_format: String,
156 pub params_file: Option<PathBuf>,
161
162 pub crud_flow: bool,
165 pub flow_config: Option<PathBuf>,
167 pub extract_fields: Option<String>,
169
170 pub parallel_create: Option<u32>,
173
174 pub data_file: Option<PathBuf>,
177 pub data_distribution: String,
179 pub data_mappings: Option<String>,
181 pub per_uri_control: bool,
183
184 pub error_rate: Option<f64>,
187 pub error_types: Option<String>,
189
190 pub security_test: bool,
193 pub security_payloads: Option<PathBuf>,
195 pub security_categories: Option<String>,
197 pub security_target_fields: Option<String>,
199
200 pub wafbench_dir: Option<String>,
203 pub wafbench_cycle_all: bool,
205
206 pub conformance: bool,
209 pub conformance_api_key: Option<String>,
211 pub conformance_basic_auth: Option<String>,
213 pub conformance_report: PathBuf,
215 pub conformance_categories: Option<String>,
217 pub conformance_report_format: String,
219 pub conformance_headers: Vec<String>,
222 pub conformance_all_operations: bool,
225 pub conformance_custom: Option<PathBuf>,
227 pub conformance_delay_ms: u64,
230 pub use_k6: bool,
232 pub conformance_custom_filter: Option<String>,
236 pub export_requests: bool,
239 pub validate_requests: bool,
242 pub conformance_self_test: bool,
249 pub conformance_self_test_capture: bool,
253 pub validate_response_schemas: bool,
259 pub conformance_self_test_iterations: u32,
264 pub conformance_self_test_duration: Option<String>,
269
270 pub source_ips: Vec<String>,
275 pub geo_source_ips: Vec<String>,
279 pub geo_source_headers: Vec<String>,
283
284 pub report_missed_cap: Option<u32>,
291
292 pub discard_response_bodies: bool,
299
300 pub dns_policy: Option<String>,
306
307 pub owasp_api_top10: bool,
310 pub owasp_categories: Option<String>,
312 pub owasp_auth_header: String,
314 pub owasp_auth_token: Option<String>,
316 pub owasp_admin_paths: Option<PathBuf>,
318 pub owasp_id_fields: Option<String>,
320 pub owasp_report: Option<PathBuf>,
322 pub owasp_report_format: String,
324 pub owasp_iterations: u32,
326}
327
328fn parse_ip_list(raw: &[String], flag_name: &str) -> Vec<std::net::IpAddr> {
342 use std::net::IpAddr;
343 const MAX_CIDR_EXPANSION: usize = 256;
344 let mut out = Vec::new();
345 for entry in raw {
346 for piece in entry.split(',') {
347 let s = piece.trim();
348 if s.is_empty() {
349 continue;
350 }
351 if let Some((addr_part, prefix_part)) = s.split_once('/') {
353 let prefix: u32 = match prefix_part.parse() {
354 Ok(p) => p,
355 Err(e) => {
356 tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad CIDR prefix: {e}");
357 continue;
358 }
359 };
360 let net_addr: IpAddr = match addr_part.parse() {
361 Ok(a) => a,
362 Err(e) => {
363 tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad CIDR address: {e}");
364 continue;
365 }
366 };
367 expand_cidr(net_addr, prefix, MAX_CIDR_EXPANSION, flag_name, s, &mut out);
368 continue;
369 }
370 if let Some((start_str, end_str)) = s.split_once('-') {
376 let start_s = start_str.trim();
377 let end_s = end_str.trim();
378 if start_s.contains(':') || end_s.contains(':') {
382 tracing::warn!(target: "mockforge::bench", "--{flag_name} '{s}': IPv6 range syntax not supported (use CIDR like 2001:db8::/126 instead)");
383 continue;
384 }
385 let start: IpAddr = match start_s.parse() {
386 Ok(a) => a,
387 Err(e) => {
388 tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad range start: {e}");
389 continue;
390 }
391 };
392 let end: IpAddr = match end_s.parse() {
393 Ok(a) => a,
394 Err(e) => {
395 tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad range end: {e}");
396 continue;
397 }
398 };
399 expand_range(start, end, MAX_CIDR_EXPANSION, flag_name, s, &mut out);
400 continue;
401 }
402 match s.parse::<IpAddr>() {
404 Ok(ip) => out.push(ip),
405 Err(e) => {
406 tracing::warn!(target: "mockforge::bench", "ignoring malformed --{flag_name} value '{s}': {e}");
407 }
408 }
409 }
410 }
411 out
412}
413
414fn expand_range(
418 start: std::net::IpAddr,
419 end: std::net::IpAddr,
420 cap: usize,
421 flag_name: &str,
422 raw: &str,
423 out: &mut Vec<std::net::IpAddr>,
424) {
425 use std::net::{IpAddr, Ipv4Addr};
426 let (start_v4, end_v4) = match (start, end) {
427 (IpAddr::V4(a), IpAddr::V4(b)) => (a, b),
428 _ => {
429 tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range start/end must both be IPv4");
430 return;
431 }
432 };
433 let start_u32 = u32::from(start_v4);
434 let end_u32 = u32::from(end_v4);
435 if end_u32 < start_u32 {
436 tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range end {end_v4} is before start {start_v4}");
437 return;
438 }
439 let total = (end_u32 - start_u32).saturating_add(1) as usize;
440 let take = total.min(cap);
441 if total > cap {
442 tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range has {total} addresses, capping at {cap}");
443 }
444 for i in 0..take as u32 {
445 out.push(IpAddr::V4(Ipv4Addr::from(start_u32 + i)));
446 }
447}
448
449fn expand_cidr(
453 net: std::net::IpAddr,
454 prefix: u32,
455 cap: usize,
456 flag_name: &str,
457 raw: &str,
458 out: &mut Vec<std::net::IpAddr>,
459) {
460 use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
461 match net {
462 IpAddr::V4(ipv4) => {
463 if prefix > 32 {
464 tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{raw}': IPv4 prefix must be <= 32");
465 return;
466 }
467 let total: u64 = 1u64 << (32 - prefix);
468 let take = total.min(cap as u64) as u32;
469 if total > cap as u64 {
470 tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': CIDR has {total} addresses, capping at {cap}");
471 }
472 let mask: u32 = if prefix == 0 {
473 0
474 } else {
475 !0u32 << (32 - prefix)
476 };
477 let net_u32 = u32::from(ipv4) & mask;
478 for i in 0..take {
479 out.push(IpAddr::V4(Ipv4Addr::from(net_u32.wrapping_add(i))));
480 }
481 }
482 IpAddr::V6(ipv6) => {
483 if prefix > 128 {
484 tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{raw}': IPv6 prefix must be <= 128");
485 return;
486 }
487 let mask: u128 = if prefix == 0 {
491 0
492 } else {
493 !0u128 << (128 - prefix)
494 };
495 let net_u128 = u128::from(ipv6) & mask;
496 let remaining_bits = 128 - prefix;
497 let total_capped = if remaining_bits >= 64 {
500 cap as u128
501 } else {
502 (1u128 << remaining_bits).min(cap as u128)
503 };
504 if remaining_bits < 128 && (1u128 << remaining_bits) > cap as u128 {
505 tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': IPv6 CIDR exceeds {cap} addresses, capping");
506 }
507 for i in 0..total_capped {
508 out.push(IpAddr::V6(Ipv6Addr::from(net_u128.wrapping_add(i))));
509 }
510 }
511 }
512}
513
514impl BenchCommand {
515 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
517 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
518
519 if !self.spec.is_empty() {
521 let specs = load_specs_from_files(self.spec.clone())
522 .await
523 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
524 all_specs.extend(specs);
525 }
526
527 if let Some(spec_dir) = &self.spec_dir {
529 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
530 BenchError::Other(format!("Failed to load specs from directory: {}", e))
531 })?;
532 all_specs.extend(dir_specs);
533 }
534
535 if all_specs.is_empty() {
536 return Err(BenchError::Other(
537 "No spec files provided. Use --spec or --spec-dir.".to_string(),
538 ));
539 }
540
541 if all_specs.len() == 1 {
543 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
545 }
546
547 let conflict_strategy = match self.merge_conflicts.as_str() {
549 "first" => ConflictStrategy::First,
550 "last" => ConflictStrategy::Last,
551 _ => ConflictStrategy::Error,
552 };
553
554 merge_specs(all_specs, conflict_strategy)
555 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
556 }
557
558 fn get_spec_display_name(&self) -> String {
560 if self.spec.len() == 1 {
561 self.spec[0].to_string_lossy().to_string()
562 } else if !self.spec.is_empty() {
563 format!("{} spec files", self.spec.len())
564 } else if let Some(dir) = &self.spec_dir {
565 format!("specs from {}", dir.display())
566 } else {
567 "no specs".to_string()
568 }
569 }
570
571 fn advise_capacity(&self) {
578 let target_count = self
579 .targets_file
580 .as_ref()
581 .and_then(|p| std::fs::read_to_string(p).ok())
582 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
583 .and_then(|v| v.as_array().map(|a| a.len()))
584 .unwrap_or(1);
585 let vus = self.vus.max(1);
586 let rps_total = self.target_rps.unwrap_or(0) as usize * target_count.max(1);
587 let load_product = target_count * vus as usize;
591 if load_product >= 150 {
592 let est_ram_gb =
593 (vus as usize * 50) / 1024 + (target_count * 10 * 2) / 1024 + target_count / 2;
594 let est_cores = ((vus as usize) / 50).max(2);
595 TerminalReporter::print_warning(&format!(
596 "Capacity advisory: targets={target_count}, VUs={vus}, RPS-total≈{rps_total}. \
597 Single-client estimate: ~{est_cores} CPU cores, ~{est_ram_gb} GB RAM. \
598 If your machine is below that, expect OOM hangs partway through the run. \
599 See https://docs.mockforge.dev/reference/bench-capacity-sizing.html \
600 for the sizing table and sharding guide."
601 ));
602 }
603 }
604
605 pub async fn execute(&self) -> Result<()> {
607 if self.conformance_self_test && self.use_k6 {
614 TerminalReporter::print_warning(
615 "--use-k6 has no effect with --conformance-self-test: the self-test driver runs and returns before k6 is invoked. Drop one or the other depending on whether you want the spec-driven self-test or a k6 bench run.",
616 );
617 }
618
619 self.advise_capacity();
625
626 if let Some(targets_file) = &self.targets_file {
628 if self.conformance && self.conformance_self_test {
637 return self.execute_multi_target_self_test(targets_file).await;
638 }
639 if self.conformance {
640 return self.execute_multi_target_conformance(targets_file).await;
641 }
642 return self.execute_multi_target(targets_file).await;
643 }
644
645 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
647 return self.execute_sequential_specs().await;
648 }
649
650 TerminalReporter::print_header(
653 &self.get_spec_display_name(),
654 &self.target,
655 0, &self.scenario,
657 Self::parse_duration(&self.duration)?,
658 );
659
660 if !K6Executor::is_k6_installed() {
662 TerminalReporter::print_error("k6 is not installed");
663 TerminalReporter::print_warning(
664 "Install k6 from: https://k6.io/docs/get-started/installation/",
665 );
666 return Err(BenchError::K6NotFound);
667 }
668
669 if self.conformance {
671 return self.execute_conformance_test().await;
672 }
673
674 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
676 let merged_spec = self.load_and_merge_specs().await?;
677 let parser = SpecParser::from_spec(merged_spec);
678 if self.spec.len() > 1 || self.spec_dir.is_some() {
679 TerminalReporter::print_success(&format!(
680 "Loaded and merged {} specification(s)",
681 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
682 ));
683 } else {
684 TerminalReporter::print_success("Specification loaded");
685 }
686
687 let mock_config = self.build_mock_config().await;
689 if mock_config.is_mock_server {
690 TerminalReporter::print_progress("Mock server integration enabled");
691 }
692
693 if self.crud_flow {
695 return self.execute_crud_flow(&parser).await;
696 }
697
698 if self.owasp_api_top10 {
700 return self.execute_owasp_test(&parser).await;
701 }
702
703 TerminalReporter::print_progress("Extracting API operations...");
705 let mut operations = if let Some(filter) = &self.operations {
706 parser.filter_operations(filter)?
707 } else {
708 parser.get_operations()
709 };
710
711 if let Some(exclude) = &self.exclude_operations {
713 let before_count = operations.len();
714 operations = parser.exclude_operations(operations, exclude)?;
715 let excluded_count = before_count - operations.len();
716 if excluded_count > 0 {
717 TerminalReporter::print_progress(&format!(
718 "Excluded {} operations matching '{}'",
719 excluded_count, exclude
720 ));
721 }
722 }
723
724 if operations.is_empty() {
725 return Err(BenchError::Other("No operations found in spec".to_string()));
726 }
727
728 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
729
730 let param_overrides = if let Some(params_file) = &self.params_file {
732 TerminalReporter::print_progress("Loading parameter overrides...");
733 let overrides = ParameterOverrides::from_file(params_file)?;
734 TerminalReporter::print_success(&format!(
735 "Loaded parameter overrides ({} operation-specific, {} defaults)",
736 overrides.operations.len(),
737 if overrides.defaults.is_empty() { 0 } else { 1 }
738 ));
739 Some(overrides)
740 } else {
741 None
742 };
743
744 TerminalReporter::print_progress("Generating request templates...");
746 let templates: Vec<_> = operations
747 .iter()
748 .map(|op| {
749 let op_overrides = param_overrides.as_ref().map(|po| {
750 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
751 });
752 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
753 })
754 .collect::<Result<Vec<_>>>()?;
755 TerminalReporter::print_success("Request templates generated");
756
757 let custom_headers = self.parse_headers()?;
759
760 let base_path = self.resolve_base_path(&parser);
762 if let Some(ref bp) = base_path {
763 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
764 }
765
766 TerminalReporter::print_progress("Generating k6 load test script...");
768 let scenario =
769 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
770
771 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
772
773 let num_ops = operations.len() as u32;
791 if let Some(rps) = self.target_rps {
792 let probe =
793 crate::preflight::probe_target_latency(&self.target, 3, self.skip_tls_verify).await;
794
795 let (required_vus, basis) = match probe {
796 Some(p) => (
797 p.required_vus(rps, num_ops),
798 format!("avg {:.1}ms (measured)", p.avg_latency.as_secs_f64() * 1000.0),
799 ),
800 None => {
801 let fallback = (rps as u64)
803 .saturating_mul(num_ops.max(1) as u64)
804 .div_ceil(10)
805 .min(u32::MAX as u64) as u32;
806 (fallback, "~100ms (default — probe failed)".to_string())
807 }
808 };
809
810 if self.vus < required_vus {
811 const VU_RECOMMENDATION_CAP: u32 = 1000;
817 let recommendation = required_vus.max(self.vus + 1);
818 if recommendation > VU_RECOMMENDATION_CAP {
819 TerminalReporter::print_warning(&format!(
820 "Workload is very large: --rps {} × {} ops/iteration × {} \
821 baseline ⇒ ~{} VUs needed end-to-end, far beyond what's \
822 practical to drive. Two ways to fix:\n 1. Reduce \
823 operations per iteration with `--operations 'pattern,…'` \
824 (or `--exclude-operations`) to focus the bench on a \
825 representative subset.\n 2. Drop `--rps` and use \
826 `--vus {}` alone — closed-model load runs as fast as \
827 the VU pool allows, bounded by latency, with no per-\
828 iteration deadline. Expect 1-iteration coverage of ~{} \
829 operations in {}s.",
830 rps,
831 num_ops,
832 basis,
833 recommendation,
834 self.vus.max(5),
835 num_ops,
836 Self::parse_duration(&self.duration).unwrap_or(0),
837 ));
838 } else {
839 TerminalReporter::print_warning(&format!(
840 "--vus {} may be insufficient for --rps {} × {} ops/iteration \
841 (baseline latency {}). k6's constant-arrival-rate counts ITERATIONS \
842 and each runs every operation in the spec — required ≈ rps × ops × \
843 latency_secs VUs. Bump --vus to ~{} if you see \"Insufficient VUs\" \
844 warnings.",
845 self.vus, rps, num_ops, basis, recommendation,
846 ));
847 }
848 } else if probe.is_some() {
849 TerminalReporter::print_progress(&format!(
850 "Pre-flight probe: target latency {}, {} ops/iteration — --vus {} \
851 is sufficient for --rps {}",
852 basis, num_ops, self.vus, rps,
853 ));
854 }
855 }
856
857 let k6_config = K6Config {
858 target_url: self.target.clone(),
859 base_path,
860 scenario,
861 duration_secs: Self::parse_duration(&self.duration)?,
862 max_vus: self.vus,
863 threshold_percentile: self.threshold_percentile.clone(),
864 threshold_ms: self.threshold_ms,
865 max_error_rate: self.max_error_rate,
866 auth_header: self.auth.clone(),
867 custom_headers,
868 skip_tls_verify: self.skip_tls_verify,
869 security_testing_enabled,
870 chunked_request_bodies: self.chunked_request_bodies,
871 target_rps: self.target_rps,
872 no_keep_alive: self.no_keep_alive,
873 geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip")
879 .into_iter()
880 .map(|ip| ip.to_string())
881 .collect(),
882 geo_source_headers: if self.geo_source_headers.is_empty()
883 && !self.geo_source_ips.is_empty()
884 {
885 crate::conformance::self_test::default_geo_source_headers()
886 } else {
887 self.geo_source_headers.clone()
888 },
889 };
890
891 let generator = K6ScriptGenerator::new(k6_config, templates)
892 .with_abort_valve(self.abort_on_error, self.abort_on_error_rate);
893 let mut script = generator.generate()?;
894 TerminalReporter::print_success("k6 script generated");
895
896 let has_advanced_features = self.data_file.is_some()
898 || self.error_rate.is_some()
899 || self.security_test
900 || self.parallel_create.is_some()
901 || self.wafbench_dir.is_some();
902
903 if has_advanced_features {
905 script = self.generate_enhanced_script(&script)?;
906 }
907
908 if mock_config.is_mock_server {
910 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
911 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
912 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
913
914 if let Some(import_end) = script.find("export const options") {
916 script.insert_str(
917 import_end,
918 &format!(
919 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
920 helper_code, setup_code, teardown_code
921 ),
922 );
923 }
924 }
925
926 TerminalReporter::print_progress("Validating k6 script...");
928 let validation_errors = K6ScriptGenerator::validate_script(&script);
929 if !validation_errors.is_empty() {
930 TerminalReporter::print_error("Script validation failed");
931 for error in &validation_errors {
932 eprintln!(" {}", error);
933 }
934 return Err(BenchError::Other(format!(
935 "Generated k6 script has {} validation error(s). Please check the output above.",
936 validation_errors.len()
937 )));
938 }
939 TerminalReporter::print_success("Script validation passed");
940
941 let script_path = if let Some(output) = &self.script_output {
943 output.clone()
944 } else {
945 self.output.join("k6-script.js")
946 };
947
948 if let Some(parent) = script_path.parent() {
949 std::fs::create_dir_all(parent)?;
950 }
951 std::fs::write(&script_path, &script)?;
952 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
953
954 if self.generate_only {
956 println!("\nScript generated successfully. Run it with:");
957 println!(" k6 run {}", script_path.display());
958 return Ok(());
959 }
960
961 TerminalReporter::print_progress("Executing load test...");
963 let executor = K6Executor::new()?
967 .with_local_ips(self.source_ips.join(","))
968 .with_dns_policy(self.dns_policy.clone().unwrap_or_default())
969 .with_discard_response_bodies(self.discard_response_bodies);
970
971 std::fs::create_dir_all(&self.output)?;
972
973 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
974
975 let duration_secs = Self::parse_duration(&self.duration)?;
977 TerminalReporter::print_summary_full(
978 &results,
979 duration_secs,
980 self.no_keep_alive,
981 Some(num_ops),
982 );
983
984 println!("\nResults saved to: {}", self.output.display());
985
986 Ok(())
987 }
988
989 async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
991 TerminalReporter::print_progress("Parsing targets file...");
992 let targets = parse_targets_file(targets_file)?;
993 let num_targets = targets.len();
994 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
995
996 if targets.is_empty() {
997 return Err(BenchError::Other("No targets found in file".to_string()));
998 }
999
1000 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
1002 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
1006 &self.get_spec_display_name(),
1007 &format!("{} targets", num_targets),
1008 0,
1009 &self.scenario,
1010 Self::parse_duration(&self.duration)?,
1011 );
1012
1013 let executor = ParallelExecutor::new(
1015 BenchCommand {
1016 spec: self.spec.clone(),
1018 spec_dir: self.spec_dir.clone(),
1019 merge_conflicts: self.merge_conflicts.clone(),
1020 spec_mode: self.spec_mode.clone(),
1021 dependency_config: self.dependency_config.clone(),
1022 target: self.target.clone(), base_path: self.base_path.clone(),
1024 duration: self.duration.clone(),
1025 vus: self.vus,
1026 target_rps: self.target_rps,
1027 no_keep_alive: self.no_keep_alive,
1028 scenario: self.scenario.clone(),
1029 operations: self.operations.clone(),
1030 exclude_operations: self.exclude_operations.clone(),
1031 auth: self.auth.clone(),
1032 headers: self.headers.clone(),
1033 output: self.output.clone(),
1034 generate_only: self.generate_only,
1035 script_output: self.script_output.clone(),
1036 threshold_percentile: self.threshold_percentile.clone(),
1037 threshold_ms: self.threshold_ms,
1038 max_error_rate: self.max_error_rate,
1039 abort_on_error: self.abort_on_error,
1040 abort_on_error_rate: self.abort_on_error_rate,
1041 verbose: self.verbose,
1042 skip_tls_verify: self.skip_tls_verify,
1043 chunked_request_bodies: self.chunked_request_bodies,
1044 targets_file: None,
1045 max_concurrency: None,
1046 results_format: self.results_format.clone(),
1047 params_file: self.params_file.clone(),
1048 crud_flow: self.crud_flow,
1049 flow_config: self.flow_config.clone(),
1050 extract_fields: self.extract_fields.clone(),
1051 parallel_create: self.parallel_create,
1052 data_file: self.data_file.clone(),
1053 data_distribution: self.data_distribution.clone(),
1054 data_mappings: self.data_mappings.clone(),
1055 per_uri_control: self.per_uri_control,
1056 error_rate: self.error_rate,
1057 error_types: self.error_types.clone(),
1058 security_test: self.security_test,
1059 security_payloads: self.security_payloads.clone(),
1060 security_categories: self.security_categories.clone(),
1061 security_target_fields: self.security_target_fields.clone(),
1062 wafbench_dir: self.wafbench_dir.clone(),
1063 wafbench_cycle_all: self.wafbench_cycle_all,
1064 owasp_api_top10: self.owasp_api_top10,
1065 owasp_categories: self.owasp_categories.clone(),
1066 owasp_auth_header: self.owasp_auth_header.clone(),
1067 owasp_auth_token: self.owasp_auth_token.clone(),
1068 owasp_admin_paths: self.owasp_admin_paths.clone(),
1069 owasp_id_fields: self.owasp_id_fields.clone(),
1070 owasp_report: self.owasp_report.clone(),
1071 owasp_report_format: self.owasp_report_format.clone(),
1072 owasp_iterations: self.owasp_iterations,
1073 conformance: false,
1074 conformance_api_key: self.conformance_api_key.clone(),
1090 conformance_basic_auth: self.conformance_basic_auth.clone(),
1091 conformance_report: PathBuf::from("conformance-report.json"),
1092 conformance_categories: None,
1093 conformance_report_format: "json".to_string(),
1094 conformance_headers: self.conformance_headers.clone(),
1098 conformance_all_operations: false,
1099 conformance_custom: None,
1100 conformance_delay_ms: 0,
1101 use_k6: false,
1102 conformance_custom_filter: None,
1103 export_requests: false,
1104 validate_requests: false,
1105 conformance_self_test: false,
1106 conformance_self_test_capture: false,
1107 conformance_self_test_iterations: 1,
1108 conformance_self_test_duration: None,
1109 validate_response_schemas: false,
1110 source_ips: self.source_ips.clone(),
1115 geo_source_ips: self.geo_source_ips.clone(),
1116 geo_source_headers: self.geo_source_headers.clone(),
1117 report_missed_cap: None,
1118 discard_response_bodies: self.discard_response_bodies,
1122 dns_policy: self.dns_policy.clone(),
1125 },
1126 targets,
1127 max_concurrency,
1128 );
1129
1130 let start_time = std::time::Instant::now();
1132 let aggregated_results = executor.execute_all().await?;
1133 let elapsed = start_time.elapsed();
1134
1135 self.report_multi_target_results(&aggregated_results, elapsed)?;
1137
1138 Ok(())
1139 }
1140
1141 fn report_multi_target_results(
1143 &self,
1144 results: &AggregatedResults,
1145 elapsed: std::time::Duration,
1146 ) -> Result<()> {
1147 TerminalReporter::print_multi_target_summary(results);
1149
1150 let total_secs = elapsed.as_secs();
1152 let hours = total_secs / 3600;
1153 let minutes = (total_secs % 3600) / 60;
1154 let seconds = total_secs % 60;
1155 if hours > 0 {
1156 println!("\n Total Elapsed Time: {}h {}m {}s", hours, minutes, seconds);
1157 } else if minutes > 0 {
1158 println!("\n Total Elapsed Time: {}m {}s", minutes, seconds);
1159 } else {
1160 println!("\n Total Elapsed Time: {}s", seconds);
1161 }
1162
1163 if self.results_format == "aggregated" || self.results_format == "both" {
1165 let summary_path = self.output.join("aggregated_summary.json");
1166 let summary_json = serde_json::json!({
1167 "total_elapsed_seconds": elapsed.as_secs(),
1168 "total_targets": results.total_targets,
1169 "successful_targets": results.successful_targets,
1170 "failed_targets": results.failed_targets,
1171 "aggregated_metrics": {
1172 "total_requests": results.aggregated_metrics.total_requests,
1173 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
1174 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
1175 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
1176 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
1177 "error_rate": results.aggregated_metrics.error_rate,
1178 "total_rps": results.aggregated_metrics.total_rps,
1179 "avg_rps": results.aggregated_metrics.avg_rps,
1180 "total_vus_max": results.aggregated_metrics.total_vus_max,
1181 },
1182 "target_results": results.target_results.iter().map(|r| {
1183 serde_json::json!({
1184 "target_url": r.target_url,
1185 "target_index": r.target_index,
1186 "success": r.success,
1187 "error": r.error,
1188 "total_requests": r.results.total_requests,
1189 "failed_requests": r.results.failed_requests,
1190 "avg_duration_ms": r.results.avg_duration_ms,
1191 "min_duration_ms": r.results.min_duration_ms,
1192 "med_duration_ms": r.results.med_duration_ms,
1193 "p90_duration_ms": r.results.p90_duration_ms,
1194 "p95_duration_ms": r.results.p95_duration_ms,
1195 "p99_duration_ms": r.results.p99_duration_ms,
1196 "max_duration_ms": r.results.max_duration_ms,
1197 "rps": r.results.rps,
1198 "vus_max": r.results.vus_max,
1199 "output_dir": r.output_dir.to_string_lossy(),
1200 })
1201 }).collect::<Vec<_>>(),
1202 });
1203
1204 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
1205 TerminalReporter::print_success(&format!(
1206 "Aggregated summary saved to: {}",
1207 summary_path.display()
1208 ));
1209 }
1210
1211 let csv_path = self.output.join("all_targets.csv");
1213 let mut csv = String::from(
1214 "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
1215 );
1216 for r in &results.target_results {
1217 csv.push_str(&format!(
1218 "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
1219 r.target_url,
1220 r.success,
1221 r.results.total_requests,
1222 r.results.failed_requests,
1223 r.results.rps,
1224 r.results.vus_max,
1225 r.results.min_duration_ms,
1226 r.results.avg_duration_ms,
1227 r.results.med_duration_ms,
1228 r.results.p90_duration_ms,
1229 r.results.p95_duration_ms,
1230 r.results.p99_duration_ms,
1231 r.results.max_duration_ms,
1232 r.error.as_deref().unwrap_or(""),
1233 ));
1234 }
1235 let _ = std::fs::write(&csv_path, &csv);
1236
1237 println!("\nResults saved to: {}", self.output.display());
1238 println!(" - Per-target results: {}", self.output.join("target_*").display());
1239 println!(" - All targets CSV: {}", csv_path.display());
1240 if self.results_format == "aggregated" || self.results_format == "both" {
1241 println!(
1242 " - Aggregated summary: {}",
1243 self.output.join("aggregated_summary.json").display()
1244 );
1245 }
1246
1247 Ok(())
1248 }
1249
1250 pub fn parse_duration(duration: &str) -> Result<u64> {
1252 let duration = duration.trim();
1253
1254 if let Some(secs) = duration.strip_suffix('s') {
1255 secs.parse::<u64>()
1256 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1257 } else if let Some(mins) = duration.strip_suffix('m') {
1258 mins.parse::<u64>()
1259 .map(|m| m * 60)
1260 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1261 } else if let Some(hours) = duration.strip_suffix('h') {
1262 hours
1263 .parse::<u64>()
1264 .map(|h| h * 3600)
1265 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1266 } else {
1267 duration
1269 .parse::<u64>()
1270 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1271 }
1272 }
1273
1274 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
1276 let mut headers = parse_header_string(&self.headers)?;
1277
1278 let already_has = |hs: &HashMap<String, String>, name: &str| -> bool {
1289 hs.keys().any(|k| k.eq_ignore_ascii_case(name))
1290 };
1291
1292 if !already_has(&headers, "Authorization") {
1293 if let Some(b) = self.conformance_basic_auth.as_ref().filter(|s| !s.is_empty()) {
1294 use base64::Engine as _;
1295 let encoded = base64::engine::general_purpose::STANDARD.encode(b.as_bytes());
1296 headers.insert("Authorization".to_string(), format!("Basic {}", encoded));
1297 }
1298 }
1299
1300 for line in &self.conformance_headers {
1306 let Some((name, value)) = line.split_once(':') else {
1307 continue;
1308 };
1309 let name = name.trim();
1310 let value = value.trim();
1311 if name.is_empty() || already_has(&headers, name) {
1312 continue;
1313 }
1314 headers.insert(name.to_string(), value.to_string());
1315 }
1316
1317 if !self.conformance && self.conformance_api_key.is_some() {
1323 TerminalReporter::print_warning(
1324 "--conformance-api-key only fires under --conformance. For plain bench use --header 'X-API-Key: ...'.",
1325 );
1326 }
1327
1328 Ok(headers)
1329 }
1330
1331 fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
1332 let extracted_path = output_dir.join("extracted_values.json");
1333 if !extracted_path.exists() {
1334 return Ok(ExtractedValues::new());
1335 }
1336
1337 let content = std::fs::read_to_string(&extracted_path)
1338 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
1339 let parsed: serde_json::Value = serde_json::from_str(&content)
1340 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
1341
1342 let mut extracted = ExtractedValues::new();
1343 if let Some(values) = parsed.as_object() {
1344 for (key, value) in values {
1345 extracted.set(key.clone(), value.clone());
1346 }
1347 }
1348
1349 Ok(extracted)
1350 }
1351
1352 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
1361 if let Some(cli_base_path) = &self.base_path {
1363 if cli_base_path.is_empty() {
1364 return None;
1366 }
1367 return Some(cli_base_path.clone());
1368 }
1369
1370 parser.get_base_path()
1372 }
1373
1374 async fn build_mock_config(&self) -> MockIntegrationConfig {
1376 if MockServerDetector::looks_like_mock_server(&self.target) {
1378 if let Ok(info) = MockServerDetector::detect(&self.target).await {
1380 if info.is_mockforge {
1381 TerminalReporter::print_success(&format!(
1382 "Detected MockForge server (version: {})",
1383 info.version.as_deref().unwrap_or("unknown")
1384 ));
1385 return MockIntegrationConfig::mock_server();
1386 }
1387 }
1388 }
1389 MockIntegrationConfig::real_api()
1390 }
1391
1392 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
1394 if !self.crud_flow {
1395 return None;
1396 }
1397
1398 if let Some(config_path) = &self.flow_config {
1400 match CrudFlowConfig::from_file(config_path) {
1401 Ok(config) => return Some(config),
1402 Err(e) => {
1403 TerminalReporter::print_warning(&format!(
1404 "Failed to load flow config: {}. Using auto-detection.",
1405 e
1406 ));
1407 }
1408 }
1409 }
1410
1411 let extract_fields = self
1413 .extract_fields
1414 .as_ref()
1415 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
1416 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
1417
1418 Some(CrudFlowConfig {
1419 flows: Vec::new(), default_extract_fields: extract_fields,
1421 })
1422 }
1423
1424 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
1426 let data_file = self.data_file.as_ref()?;
1427
1428 let distribution = DataDistribution::from_str(&self.data_distribution)
1429 .unwrap_or(DataDistribution::UniquePerVu);
1430
1431 let mappings = self
1432 .data_mappings
1433 .as_ref()
1434 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
1435 .unwrap_or_default();
1436
1437 Some(DataDrivenConfig {
1438 file_path: data_file.to_string_lossy().to_string(),
1439 distribution,
1440 mappings,
1441 csv_has_header: true,
1442 per_uri_control: self.per_uri_control,
1443 per_uri_columns: crate::data_driven::PerUriColumns::default(),
1444 })
1445 }
1446
1447 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
1449 let error_rate = self.error_rate?;
1450
1451 let error_types = self
1452 .error_types
1453 .as_ref()
1454 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
1455 .unwrap_or_default();
1456
1457 Some(InvalidDataConfig {
1458 error_rate,
1459 error_types,
1460 target_fields: Vec::new(),
1461 })
1462 }
1463
1464 fn build_security_config(&self) -> Option<SecurityTestConfig> {
1466 if !self.security_test {
1467 return None;
1468 }
1469
1470 let categories = self
1471 .security_categories
1472 .as_ref()
1473 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
1474 .unwrap_or_else(|| {
1475 let mut default = HashSet::new();
1476 default.insert(SecurityCategory::SqlInjection);
1477 default.insert(SecurityCategory::Xss);
1478 default
1479 });
1480
1481 let target_fields = self
1482 .security_target_fields
1483 .as_ref()
1484 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
1485 .unwrap_or_default();
1486
1487 let custom_payloads_file =
1488 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
1489
1490 Some(SecurityTestConfig {
1491 enabled: true,
1492 categories,
1493 target_fields,
1494 custom_payloads_file,
1495 include_high_risk: false,
1496 })
1497 }
1498
1499 fn build_parallel_config(&self) -> Option<ParallelConfig> {
1501 let count = self.parallel_create?;
1502
1503 Some(ParallelConfig::new(count))
1504 }
1505
1506 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
1508 let Some(ref wafbench_dir) = self.wafbench_dir else {
1509 return Vec::new();
1510 };
1511
1512 let mut loader = WafBenchLoader::new();
1513
1514 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
1515 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
1516 return Vec::new();
1517 }
1518
1519 let stats = loader.stats();
1520
1521 if stats.files_processed == 0 {
1522 TerminalReporter::print_warning(&format!(
1523 "No WAFBench YAML files found matching '{}'",
1524 wafbench_dir
1525 ));
1526 if !stats.parse_errors.is_empty() {
1528 TerminalReporter::print_warning("Some files were found but failed to parse:");
1529 for error in &stats.parse_errors {
1530 TerminalReporter::print_warning(&format!(" - {}", error));
1531 }
1532 }
1533 return Vec::new();
1534 }
1535
1536 TerminalReporter::print_progress(&format!(
1537 "Loaded {} WAFBench files, {} test cases, {} payloads",
1538 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
1539 ));
1540
1541 for (category, count) in &stats.by_category {
1543 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
1544 }
1545
1546 for error in &stats.parse_errors {
1548 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
1549 }
1550
1551 loader.to_security_payloads()
1552 }
1553
1554 pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
1556 let mut enhanced_script = base_script.to_string();
1557 let mut additional_code = String::new();
1558
1559 if let Some(config) = self.build_data_driven_config() {
1561 TerminalReporter::print_progress("Adding data-driven testing support...");
1562 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
1563 additional_code.push('\n');
1564 TerminalReporter::print_success("Data-driven testing enabled");
1565 }
1566
1567 if let Some(config) = self.build_invalid_data_config() {
1569 TerminalReporter::print_progress("Adding invalid data testing support...");
1570 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
1571 additional_code.push('\n');
1572 additional_code
1573 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
1574 additional_code.push('\n');
1575 additional_code
1576 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
1577 additional_code.push('\n');
1578 TerminalReporter::print_success(&format!(
1579 "Invalid data testing enabled ({}% error rate)",
1580 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
1581 ));
1582 }
1583
1584 let security_config = self.build_security_config();
1586 let wafbench_payloads = self.load_wafbench_payloads();
1587 let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
1588
1589 if security_config.is_some() || !wafbench_payloads.is_empty() {
1590 TerminalReporter::print_progress("Adding security testing support...");
1591
1592 let mut payload_list: Vec<SecurityPayload> = Vec::new();
1594
1595 if let Some(ref config) = security_config {
1596 payload_list.extend(SecurityPayloads::get_payloads(config));
1597 }
1598
1599 if !wafbench_payloads.is_empty() {
1601 TerminalReporter::print_progress(&format!(
1602 "Loading {} WAFBench attack patterns...",
1603 wafbench_payloads.len()
1604 ));
1605 payload_list.extend(wafbench_payloads);
1606 }
1607
1608 let target_fields =
1609 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
1610
1611 additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
1612 &payload_list,
1613 self.wafbench_cycle_all,
1614 ));
1615 additional_code.push('\n');
1616 additional_code
1617 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
1618 additional_code.push('\n');
1619 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
1620 additional_code.push('\n');
1621
1622 let mode = if self.wafbench_cycle_all {
1623 "cycle-all"
1624 } else {
1625 "random"
1626 };
1627 TerminalReporter::print_success(&format!(
1628 "Security testing enabled ({} payloads, {} mode)",
1629 payload_list.len(),
1630 mode
1631 ));
1632 } else if security_requested {
1633 TerminalReporter::print_warning(
1637 "Security testing was requested but no payloads were loaded. \
1638 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
1639 );
1640 additional_code
1641 .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
1642 additional_code.push('\n');
1643 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1644 additional_code.push('\n');
1645 }
1646
1647 if let Some(config) = self.build_parallel_config() {
1649 TerminalReporter::print_progress("Adding parallel execution support...");
1650 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1651 additional_code.push('\n');
1652 TerminalReporter::print_success(&format!(
1653 "Parallel execution enabled (count: {})",
1654 config.count
1655 ));
1656 }
1657
1658 if !additional_code.is_empty() {
1660 if let Some(import_end) = enhanced_script.find("export const options") {
1662 enhanced_script.insert_str(
1663 import_end,
1664 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1665 );
1666 }
1667 }
1668
1669 Ok(enhanced_script)
1670 }
1671
1672 async fn execute_sequential_specs(&self) -> Result<()> {
1674 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1675
1676 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1678
1679 if !self.spec.is_empty() {
1680 let specs = load_specs_from_files(self.spec.clone())
1681 .await
1682 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1683 all_specs.extend(specs);
1684 }
1685
1686 if let Some(spec_dir) = &self.spec_dir {
1687 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1688 BenchError::Other(format!("Failed to load specs from directory: {}", e))
1689 })?;
1690 all_specs.extend(dir_specs);
1691 }
1692
1693 if all_specs.is_empty() {
1694 return Err(BenchError::Other(
1695 "No spec files found for sequential execution".to_string(),
1696 ));
1697 }
1698
1699 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1700
1701 let execution_order = if let Some(config_path) = &self.dependency_config {
1703 TerminalReporter::print_progress("Loading dependency configuration...");
1704 let config = SpecDependencyConfig::from_file(config_path)?;
1705
1706 if !config.disable_auto_detect && config.execution_order.is_empty() {
1707 self.detect_and_sort_specs(&all_specs)?
1709 } else {
1710 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1712 }
1713 } else {
1714 self.detect_and_sort_specs(&all_specs)?
1716 };
1717
1718 TerminalReporter::print_success(&format!(
1719 "Execution order: {}",
1720 execution_order
1721 .iter()
1722 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1723 .collect::<Vec<_>>()
1724 .join(" → ")
1725 ));
1726
1727 let mut extracted_values = ExtractedValues::new();
1729 let total_specs = execution_order.len();
1730
1731 for (index, spec_path) in execution_order.iter().enumerate() {
1732 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1733
1734 TerminalReporter::print_progress(&format!(
1735 "[{}/{}] Executing spec: {}",
1736 index + 1,
1737 total_specs,
1738 spec_name
1739 ));
1740
1741 let spec = all_specs
1743 .iter()
1744 .find(|(p, _)| {
1745 p == spec_path
1746 || p.file_name() == spec_path.file_name()
1747 || p.file_name() == Some(spec_path.as_os_str())
1748 })
1749 .map(|(_, s)| s.clone())
1750 .ok_or_else(|| {
1751 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1752 })?;
1753
1754 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1756
1757 extracted_values.merge(&new_values);
1759
1760 TerminalReporter::print_success(&format!(
1761 "[{}/{}] Completed: {} (extracted {} values)",
1762 index + 1,
1763 total_specs,
1764 spec_name,
1765 new_values.values.len()
1766 ));
1767 }
1768
1769 TerminalReporter::print_success(&format!(
1770 "Sequential execution complete: {} specs executed",
1771 total_specs
1772 ));
1773
1774 Ok(())
1775 }
1776
1777 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1779 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1780
1781 let mut detector = DependencyDetector::new();
1782 let dependencies = detector.detect_dependencies(specs);
1783
1784 if dependencies.is_empty() {
1785 TerminalReporter::print_progress("No dependencies detected, using file order");
1786 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1787 }
1788
1789 TerminalReporter::print_progress(&format!(
1790 "Detected {} cross-spec dependencies",
1791 dependencies.len()
1792 ));
1793
1794 for dep in &dependencies {
1795 TerminalReporter::print_progress(&format!(
1796 " {} → {} (via field '{}')",
1797 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1798 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1799 dep.field_name
1800 ));
1801 }
1802
1803 topological_sort(specs, &dependencies)
1804 }
1805
1806 async fn execute_single_spec(
1808 &self,
1809 spec: &OpenApiSpec,
1810 spec_name: &str,
1811 _external_values: &ExtractedValues,
1812 ) -> Result<ExtractedValues> {
1813 let parser = SpecParser::from_spec(spec.clone());
1814
1815 if self.crud_flow {
1817 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1819 } else {
1820 self.execute_standard_spec(&parser, spec_name).await?;
1822 Ok(ExtractedValues::new())
1823 }
1824 }
1825
1826 async fn execute_crud_flow_with_extraction(
1828 &self,
1829 parser: &SpecParser,
1830 spec_name: &str,
1831 ) -> Result<ExtractedValues> {
1832 let operations = parser.get_operations();
1833 let flows = CrudFlowDetector::detect_flows(&operations);
1834
1835 if flows.is_empty() {
1836 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1837 return Ok(ExtractedValues::new());
1838 }
1839
1840 TerminalReporter::print_progress(&format!(
1841 " {} CRUD flow(s) in {}",
1842 flows.len(),
1843 spec_name
1844 ));
1845
1846 let mut handlebars = handlebars::Handlebars::new();
1848 handlebars.register_helper(
1850 "json",
1851 Box::new(
1852 |h: &handlebars::Helper,
1853 _: &handlebars::Handlebars,
1854 _: &handlebars::Context,
1855 _: &mut handlebars::RenderContext,
1856 out: &mut dyn handlebars::Output|
1857 -> handlebars::HelperResult {
1858 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1859 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1860 Ok(())
1861 },
1862 ),
1863 );
1864 let template = include_str!("templates/k6_crud_flow.hbs");
1865 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1866
1867 let custom_headers = self.parse_headers()?;
1868 let config = self.build_crud_flow_config().unwrap_or_default();
1869
1870 let param_overrides = if let Some(params_file) = &self.params_file {
1872 let overrides = ParameterOverrides::from_file(params_file)?;
1873 Some(overrides)
1874 } else {
1875 None
1876 };
1877
1878 let duration_secs = Self::parse_duration(&self.duration)?;
1880 let scenario =
1881 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1882 let stages = scenario.generate_stages(duration_secs, self.vus);
1883
1884 let api_base_path = self.resolve_base_path(parser);
1886
1887 let mut all_headers = custom_headers.clone();
1889 if let Some(auth) = &self.auth {
1890 all_headers.insert("Authorization".to_string(), auth.clone());
1891 }
1892 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1893
1894 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1896
1897 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1898 let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
1902 serde_json::json!({
1903 "name": sanitized_name.clone(),
1904 "display_name": f.name,
1905 "base_path": f.base_path,
1906 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1907 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1909 let method_raw = if !parts.is_empty() {
1910 parts[0].to_uppercase()
1911 } else {
1912 "GET".to_string()
1913 };
1914 let method = if !parts.is_empty() {
1915 let m = parts[0].to_lowercase();
1916 if m == "delete" { "del".to_string() } else { m }
1918 } else {
1919 "get".to_string()
1920 };
1921 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1922 let path = if let Some(ref bp) = api_base_path {
1924 format!("{}{}", bp, raw_path)
1925 } else {
1926 raw_path.to_string()
1927 };
1928 let is_get_or_head = method == "get" || method == "head";
1929 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1931
1932 let body_value = if has_body {
1934 param_overrides.as_ref()
1935 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1936 .and_then(|oo| oo.body)
1937 .unwrap_or_else(|| serde_json::json!({}))
1938 } else {
1939 serde_json::json!({})
1940 };
1941
1942 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1944
1945 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1947 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1948
1949 serde_json::json!({
1950 "operation": s.operation,
1951 "method": method,
1952 "path": path,
1953 "extract": s.extract,
1954 "use_values": s.use_values,
1955 "use_body": s.use_body,
1956 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1957 "inject_attacks": s.inject_attacks,
1958 "attack_types": s.attack_types,
1959 "description": s.description,
1960 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1961 "is_get_or_head": is_get_or_head,
1962 "has_body": has_body,
1963 "body": processed_body.value,
1964 "body_is_dynamic": body_is_dynamic,
1965 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1966 })
1967 }).collect::<Vec<_>>(),
1968 })
1969 }).collect();
1970
1971 for flow_data in &flows_data {
1973 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1974 for step in steps {
1975 if let Some(placeholders_arr) =
1976 step.get("_placeholders").and_then(|p| p.as_array())
1977 {
1978 for p_str in placeholders_arr {
1979 if let Some(p_name) = p_str.as_str() {
1980 match p_name {
1981 "VU" => {
1982 all_placeholders.insert(DynamicPlaceholder::VU);
1983 }
1984 "Iteration" => {
1985 all_placeholders.insert(DynamicPlaceholder::Iteration);
1986 }
1987 "Timestamp" => {
1988 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1989 }
1990 "UUID" => {
1991 all_placeholders.insert(DynamicPlaceholder::UUID);
1992 }
1993 "Random" => {
1994 all_placeholders.insert(DynamicPlaceholder::Random);
1995 }
1996 "Counter" => {
1997 all_placeholders.insert(DynamicPlaceholder::Counter);
1998 }
1999 "Date" => {
2000 all_placeholders.insert(DynamicPlaceholder::Date);
2001 }
2002 "VuIter" => {
2003 all_placeholders.insert(DynamicPlaceholder::VuIter);
2004 }
2005 _ => {}
2006 }
2007 }
2008 }
2009 }
2010 }
2011 }
2012 }
2013
2014 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
2016 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
2017
2018 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
2020
2021 let data = serde_json::json!({
2022 "base_url": self.target,
2023 "flows": flows_data,
2024 "extract_fields": config.default_extract_fields,
2025 "duration_secs": duration_secs,
2026 "max_vus": self.vus,
2027 "auth_header": self.auth,
2028 "custom_headers": custom_headers,
2029 "skip_tls_verify": self.skip_tls_verify,
2030 "stages": stages.iter().map(|s| serde_json::json!({
2032 "duration": s.duration,
2033 "target": s.target,
2034 })).collect::<Vec<_>>(),
2035 "threshold_percentile": self.threshold_percentile,
2036 "threshold_ms": self.threshold_ms,
2037 "max_error_rate": self.max_error_rate,
2038 "abort_on_error": self.abort_on_error,
2039 "abort_on_error_rate": self.abort_on_error_rate,
2040 "headers": headers_json,
2041 "dynamic_imports": required_imports,
2042 "dynamic_globals": required_globals,
2043 "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
2044 "security_testing_enabled": security_testing_enabled,
2046 "has_custom_headers": !custom_headers.is_empty(),
2047 });
2048
2049 let mut script = handlebars
2050 .render_template(template, &data)
2051 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
2052
2053 if security_testing_enabled {
2055 script = self.generate_enhanced_script(&script)?;
2056 }
2057
2058 let script_path =
2060 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
2061
2062 std::fs::create_dir_all(self.output.clone())?;
2063 std::fs::write(&script_path, &script)?;
2064
2065 if !self.generate_only {
2066 let executor = K6Executor::new()?
2067 .with_local_ips(self.source_ips.join(","))
2068 .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
2069 std::fs::create_dir_all(&output_dir)?;
2070
2071 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
2072
2073 let extracted = Self::parse_extracted_values(&output_dir)?;
2074 TerminalReporter::print_progress(&format!(
2075 " Extracted {} value(s) from {}",
2076 extracted.values.len(),
2077 spec_name
2078 ));
2079 return Ok(extracted);
2080 }
2081
2082 Ok(ExtractedValues::new())
2083 }
2084
2085 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
2087 let mut operations = if let Some(filter) = &self.operations {
2088 parser.filter_operations(filter)?
2089 } else {
2090 parser.get_operations()
2091 };
2092
2093 if let Some(exclude) = &self.exclude_operations {
2094 operations = parser.exclude_operations(operations, exclude)?;
2095 }
2096
2097 if operations.is_empty() {
2098 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
2099 return Ok(());
2100 }
2101
2102 TerminalReporter::print_progress(&format!(
2103 " {} operations in {}",
2104 operations.len(),
2105 spec_name
2106 ));
2107
2108 let templates: Vec<_> = operations
2110 .iter()
2111 .map(RequestGenerator::generate_template)
2112 .collect::<Result<Vec<_>>>()?;
2113
2114 let custom_headers = self.parse_headers()?;
2116
2117 let base_path = self.resolve_base_path(parser);
2119
2120 let scenario =
2122 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
2123
2124 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
2125
2126 let k6_config = K6Config {
2127 target_url: self.target.clone(),
2128 base_path,
2129 scenario,
2130 duration_secs: Self::parse_duration(&self.duration)?,
2131 max_vus: self.vus,
2132 threshold_percentile: self.threshold_percentile.clone(),
2133 threshold_ms: self.threshold_ms,
2134 max_error_rate: self.max_error_rate,
2135 auth_header: self.auth.clone(),
2136 custom_headers,
2137 skip_tls_verify: self.skip_tls_verify,
2138 security_testing_enabled,
2139 chunked_request_bodies: self.chunked_request_bodies,
2140 target_rps: self.target_rps,
2141 no_keep_alive: self.no_keep_alive,
2142 geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip")
2144 .into_iter()
2145 .map(|ip| ip.to_string())
2146 .collect(),
2147 geo_source_headers: if self.geo_source_headers.is_empty()
2148 && !self.geo_source_ips.is_empty()
2149 {
2150 crate::conformance::self_test::default_geo_source_headers()
2151 } else {
2152 self.geo_source_headers.clone()
2153 },
2154 };
2155
2156 let generator = K6ScriptGenerator::new(k6_config, templates)
2157 .with_abort_valve(self.abort_on_error, self.abort_on_error_rate);
2158 let mut script = generator.generate()?;
2159
2160 let has_advanced_features = self.data_file.is_some()
2162 || self.error_rate.is_some()
2163 || self.security_test
2164 || self.parallel_create.is_some()
2165 || self.wafbench_dir.is_some();
2166
2167 if has_advanced_features {
2168 script = self.generate_enhanced_script(&script)?;
2169 }
2170
2171 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
2173
2174 std::fs::create_dir_all(self.output.clone())?;
2175 std::fs::write(&script_path, &script)?;
2176
2177 if !self.generate_only {
2178 let executor = K6Executor::new()?
2181 .with_local_ips(self.source_ips.join(","))
2182 .with_dns_policy(self.dns_policy.clone().unwrap_or_default())
2183 .with_discard_response_bodies(self.discard_response_bodies);
2184 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
2185 std::fs::create_dir_all(&output_dir)?;
2186
2187 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
2188 }
2189
2190 Ok(())
2191 }
2192
2193 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
2195 let config = self.build_crud_flow_config().unwrap_or_default();
2197
2198 let flows = if !config.flows.is_empty() {
2200 TerminalReporter::print_progress("Using custom flow configuration...");
2201 config.flows.clone()
2202 } else {
2203 TerminalReporter::print_progress("Detecting CRUD operations...");
2204 let operations = parser.get_operations();
2205 CrudFlowDetector::detect_flows(&operations)
2206 };
2207
2208 if flows.is_empty() {
2209 return Err(BenchError::Other(
2210 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
2211 ));
2212 }
2213
2214 if config.flows.is_empty() {
2215 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
2216 } else {
2217 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
2218 }
2219
2220 for flow in &flows {
2221 TerminalReporter::print_progress(&format!(
2222 " - {}: {} steps",
2223 flow.name,
2224 flow.steps.len()
2225 ));
2226 }
2227
2228 let mut handlebars = handlebars::Handlebars::new();
2230 handlebars.register_helper(
2232 "json",
2233 Box::new(
2234 |h: &handlebars::Helper,
2235 _: &handlebars::Handlebars,
2236 _: &handlebars::Context,
2237 _: &mut handlebars::RenderContext,
2238 out: &mut dyn handlebars::Output|
2239 -> handlebars::HelperResult {
2240 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
2241 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
2242 Ok(())
2243 },
2244 ),
2245 );
2246 let template = include_str!("templates/k6_crud_flow.hbs");
2247
2248 let custom_headers = self.parse_headers()?;
2249
2250 let param_overrides = if let Some(params_file) = &self.params_file {
2252 TerminalReporter::print_progress("Loading parameter overrides...");
2253 let overrides = ParameterOverrides::from_file(params_file)?;
2254 TerminalReporter::print_success(&format!(
2255 "Loaded parameter overrides ({} operation-specific, {} defaults)",
2256 overrides.operations.len(),
2257 if overrides.defaults.is_empty() { 0 } else { 1 }
2258 ));
2259 Some(overrides)
2260 } else {
2261 None
2262 };
2263
2264 let duration_secs = Self::parse_duration(&self.duration)?;
2266 let scenario =
2267 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
2268 let stages = scenario.generate_stages(duration_secs, self.vus);
2269
2270 let api_base_path = self.resolve_base_path(parser);
2272 if let Some(ref bp) = api_base_path {
2273 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
2274 }
2275
2276 let mut all_headers = custom_headers.clone();
2278 if let Some(auth) = &self.auth {
2279 all_headers.insert("Authorization".to_string(), auth.clone());
2280 }
2281 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
2282
2283 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
2285
2286 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
2287 let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
2292 serde_json::json!({
2293 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
2296 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
2297 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
2299 let method_raw = if !parts.is_empty() {
2300 parts[0].to_uppercase()
2301 } else {
2302 "GET".to_string()
2303 };
2304 let method = if !parts.is_empty() {
2305 let m = parts[0].to_lowercase();
2306 if m == "delete" { "del".to_string() } else { m }
2308 } else {
2309 "get".to_string()
2310 };
2311 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
2312 let path = if let Some(ref bp) = api_base_path {
2314 format!("{}{}", bp, raw_path)
2315 } else {
2316 raw_path.to_string()
2317 };
2318 let is_get_or_head = method == "get" || method == "head";
2319 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
2321
2322 let body_value = if has_body {
2324 param_overrides.as_ref()
2325 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
2326 .and_then(|oo| oo.body)
2327 .unwrap_or_else(|| serde_json::json!({}))
2328 } else {
2329 serde_json::json!({})
2330 };
2331
2332 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
2334 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
2339 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
2340
2341 serde_json::json!({
2342 "operation": s.operation,
2343 "method": method,
2344 "path": path,
2345 "extract": s.extract,
2346 "use_values": s.use_values,
2347 "use_body": s.use_body,
2348 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
2349 "inject_attacks": s.inject_attacks,
2350 "attack_types": s.attack_types,
2351 "description": s.description,
2352 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
2353 "is_get_or_head": is_get_or_head,
2354 "has_body": has_body,
2355 "body": processed_body.value,
2356 "body_is_dynamic": body_is_dynamic,
2357 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
2358 })
2359 }).collect::<Vec<_>>(),
2360 })
2361 }).collect();
2362
2363 for flow_data in &flows_data {
2365 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
2366 for step in steps {
2367 if let Some(placeholders_arr) =
2368 step.get("_placeholders").and_then(|p| p.as_array())
2369 {
2370 for p_str in placeholders_arr {
2371 if let Some(p_name) = p_str.as_str() {
2372 match p_name {
2374 "VU" => {
2375 all_placeholders.insert(DynamicPlaceholder::VU);
2376 }
2377 "Iteration" => {
2378 all_placeholders.insert(DynamicPlaceholder::Iteration);
2379 }
2380 "Timestamp" => {
2381 all_placeholders.insert(DynamicPlaceholder::Timestamp);
2382 }
2383 "UUID" => {
2384 all_placeholders.insert(DynamicPlaceholder::UUID);
2385 }
2386 "Random" => {
2387 all_placeholders.insert(DynamicPlaceholder::Random);
2388 }
2389 "Counter" => {
2390 all_placeholders.insert(DynamicPlaceholder::Counter);
2391 }
2392 "Date" => {
2393 all_placeholders.insert(DynamicPlaceholder::Date);
2394 }
2395 "VuIter" => {
2396 all_placeholders.insert(DynamicPlaceholder::VuIter);
2397 }
2398 _ => {}
2399 }
2400 }
2401 }
2402 }
2403 }
2404 }
2405 }
2406
2407 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
2409 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
2410
2411 let invalid_data_config = self.build_invalid_data_config();
2413 let error_injection_enabled = invalid_data_config.is_some();
2414 let error_rate = self.error_rate.unwrap_or(0.0);
2415 let error_types: Vec<String> = invalid_data_config
2416 .as_ref()
2417 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
2418 .unwrap_or_default();
2419
2420 if error_injection_enabled {
2421 TerminalReporter::print_progress(&format!(
2422 "Error injection enabled ({}% rate)",
2423 (error_rate * 100.0) as u32
2424 ));
2425 }
2426
2427 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
2429
2430 let data = serde_json::json!({
2431 "base_url": self.target,
2432 "flows": flows_data,
2433 "extract_fields": config.default_extract_fields,
2434 "duration_secs": duration_secs,
2435 "max_vus": self.vus,
2436 "auth_header": self.auth,
2437 "custom_headers": custom_headers,
2438 "skip_tls_verify": self.skip_tls_verify,
2439 "stages": stages.iter().map(|s| serde_json::json!({
2441 "duration": s.duration,
2442 "target": s.target,
2443 })).collect::<Vec<_>>(),
2444 "threshold_percentile": self.threshold_percentile,
2445 "threshold_ms": self.threshold_ms,
2446 "max_error_rate": self.max_error_rate,
2447 "abort_on_error": self.abort_on_error,
2448 "abort_on_error_rate": self.abort_on_error_rate,
2449 "headers": headers_json,
2450 "dynamic_imports": required_imports,
2451 "dynamic_globals": required_globals,
2452 "extracted_values_output_path": self
2453 .output
2454 .join("crud_flow_extracted_values.json")
2455 .to_string_lossy(),
2456 "error_injection_enabled": error_injection_enabled,
2458 "error_rate": error_rate,
2459 "error_types": error_types,
2460 "security_testing_enabled": security_testing_enabled,
2462 "has_custom_headers": !custom_headers.is_empty(),
2463 });
2464
2465 let mut script = handlebars
2466 .render_template(template, &data)
2467 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
2468
2469 if security_testing_enabled {
2471 script = self.generate_enhanced_script(&script)?;
2472 }
2473
2474 TerminalReporter::print_progress("Validating CRUD flow script...");
2476 let validation_errors = K6ScriptGenerator::validate_script(&script);
2477 if !validation_errors.is_empty() {
2478 TerminalReporter::print_error("CRUD flow script validation failed");
2479 for error in &validation_errors {
2480 eprintln!(" {}", error);
2481 }
2482 return Err(BenchError::Other(format!(
2483 "CRUD flow script validation failed with {} error(s)",
2484 validation_errors.len()
2485 )));
2486 }
2487
2488 TerminalReporter::print_success("CRUD flow script generated");
2489
2490 let script_path = if let Some(output) = &self.script_output {
2492 output.clone()
2493 } else {
2494 self.output.join("k6-crud-flow-script.js")
2495 };
2496
2497 if let Some(parent) = script_path.parent() {
2498 std::fs::create_dir_all(parent)?;
2499 }
2500 std::fs::write(&script_path, &script)?;
2501 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2502
2503 if self.generate_only {
2504 println!("\nScript generated successfully. Run it with:");
2505 println!(" k6 run {}", script_path.display());
2506 return Ok(());
2507 }
2508
2509 TerminalReporter::print_progress("Executing CRUD flow test...");
2511 let executor = K6Executor::new()?
2512 .with_local_ips(self.source_ips.join(","))
2513 .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
2514 std::fs::create_dir_all(&self.output)?;
2515
2516 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2517
2518 let duration_secs = Self::parse_duration(&self.duration)?;
2519 TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
2520
2521 Ok(())
2522 }
2523
2524 async fn execute_conformance_test(&self) -> Result<()> {
2526 use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2527 use crate::conformance::report::ConformanceReport;
2528 use crate::conformance::spec::ConformanceFeature;
2529
2530 TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
2531
2532 TerminalReporter::print_progress(CONFORMANCE_REPLACES_LOAD_ADVISORY);
2533
2534 let categories = self.conformance_categories.as_ref().map(|cats_str| {
2536 cats_str
2537 .split(',')
2538 .filter_map(|s| {
2539 let trimmed = s.trim();
2540 if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2541 Some(canonical.to_string())
2542 } else {
2543 TerminalReporter::print_warning(&format!(
2544 "Unknown conformance category: '{}'. Valid categories: {}",
2545 trimmed,
2546 ConformanceFeature::cli_category_names()
2547 .iter()
2548 .map(|(cli, _)| *cli)
2549 .collect::<Vec<_>>()
2550 .join(", ")
2551 ));
2552 None
2553 }
2554 })
2555 .collect::<Vec<String>>()
2556 });
2557
2558 let custom_headers: Vec<(String, String)> = self
2560 .conformance_headers
2561 .iter()
2562 .filter_map(|h| {
2563 let (name, value) = h.split_once(':')?;
2564 Some((name.trim().to_string(), value.trim().to_string()))
2565 })
2566 .collect();
2567
2568 if !custom_headers.is_empty() {
2569 TerminalReporter::print_progress(&format!(
2570 "Using {} custom header(s) for authentication",
2571 custom_headers.len()
2572 ));
2573 }
2574
2575 if self.conformance_delay_ms > 0 {
2576 TerminalReporter::print_progress(&format!(
2577 "Using {}ms delay between conformance requests",
2578 self.conformance_delay_ms
2579 ));
2580 }
2581
2582 std::fs::create_dir_all(&self.output)?;
2584
2585 let config = ConformanceConfig {
2586 target_url: self.target.clone(),
2587 api_key: self.conformance_api_key.clone(),
2588 basic_auth: self.conformance_basic_auth.clone(),
2589 skip_tls_verify: self.skip_tls_verify,
2590 categories,
2591 base_path: self.base_path.clone(),
2592 custom_headers,
2593 output_dir: Some(self.output.clone()),
2594 all_operations: self.conformance_all_operations,
2595 custom_checks_file: self.conformance_custom.clone(),
2596 request_delay_ms: self.conformance_delay_ms,
2597 custom_filter: self.conformance_custom_filter.clone(),
2598 export_requests: self.export_requests,
2599 validate_requests: self.validate_requests,
2600 };
2601
2602 let mut resolved_base_path: Option<String> = None;
2610 let annotated_ops = if !self.spec.is_empty() {
2611 TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2612 let parser = SpecParser::from_file(&self.spec[0]).await?;
2613 resolved_base_path = self.resolve_base_path(&parser);
2614
2615 let mut operations = if let Some(filter) = &self.operations {
2620 parser.filter_operations(filter)?
2621 } else {
2622 parser.get_operations()
2623 };
2624 if let Some(exclude) = &self.exclude_operations {
2625 let before_count = operations.len();
2626 operations = parser.exclude_operations(operations, exclude)?;
2627 let excluded_count = before_count - operations.len();
2628 if excluded_count > 0 {
2629 TerminalReporter::print_progress(&format!(
2630 "Excluded {} operations matching '{}'",
2631 excluded_count, exclude
2632 ));
2633 }
2634 }
2635
2636 let annotated =
2637 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2638 &operations,
2639 parser.spec(),
2640 );
2641 TerminalReporter::print_success(&format!(
2642 "Analyzed {} operations, found {} feature annotations",
2643 operations.len(),
2644 annotated.iter().map(|a| a.features.len()).sum::<usize>()
2645 ));
2646 Some(annotated)
2647 } else {
2648 None
2649 };
2650
2651 if self.conformance_self_test {
2658 let Some(ops) = annotated_ops else {
2659 TerminalReporter::print_error(
2660 "--conformance-self-test requires --spec; no operations to test",
2661 );
2662 return Ok(());
2663 };
2664 let cfg = crate::conformance::self_test::SelfTestConfig {
2665 target_url: self.target.clone(),
2666 skip_tls_verify: self.skip_tls_verify,
2667 timeout: std::time::Duration::from_secs(30),
2668 extra_headers: self
2672 .conformance_headers
2673 .iter()
2674 .filter_map(|h| {
2675 let (n, v) = h.split_once(':')?;
2676 Some((n.trim().to_string(), v.trim().to_string()))
2677 })
2678 .collect(),
2679 delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
2680 base_path: resolved_base_path.clone(),
2684 source_ips: parse_ip_list(&self.source_ips, "source-ip"),
2688 geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
2689 geo_source_headers: if self.geo_source_headers.is_empty() {
2690 crate::conformance::self_test::default_geo_source_headers()
2691 } else {
2692 self.geo_source_headers.clone()
2693 },
2694 capture: if self.conformance_self_test_capture
2698 || self.validate_response_schemas
2699 || self.validate_requests
2700 {
2701 Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
2712 } else {
2713 None
2714 },
2715 validate_response_schemas: self.validate_response_schemas,
2716 spec_label: self.spec.first().map(|p| {
2722 p.file_name()
2723 .map(|s| s.to_string_lossy().into_owned())
2724 .unwrap_or_else(|| p.to_string_lossy().into_owned())
2725 }),
2726 network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
2733 current_iteration: 1,
2734 };
2735 let capture_sink = cfg.capture.clone();
2736 let network_events_sink = cfg.network_events.clone();
2737 TerminalReporter::print_progress(&format!(
2738 "Self-test mode: driving {} operations with positive + per-category negative cases",
2739 ops.len()
2740 ));
2741 let target_iterations = self.conformance_self_test_iterations.max(1);
2748 let duration_budget = self
2749 .conformance_self_test_duration
2750 .as_ref()
2751 .map(|s| Self::parse_duration(s))
2752 .transpose()?
2753 .map(std::time::Duration::from_secs);
2754 let start = std::time::Instant::now();
2755 let deadline = duration_budget.map(|d| start + d);
2764 let mut cfg = cfg;
2768 cfg.current_iteration = 1;
2769 let mut report =
2770 crate::conformance::self_test::run_self_test_with_deadline(&ops, &cfg, deadline)
2771 .await
2772 .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2773 let mut iter_done: u32 = 1;
2774 loop {
2775 let by_iter = iter_done >= target_iterations;
2776 let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
2777 if by_iter && by_dur {
2778 break;
2779 }
2780 cfg.current_iteration = iter_done.saturating_add(1);
2781 let next = crate::conformance::self_test::run_self_test_with_deadline(
2782 &ops, &cfg, deadline,
2783 )
2784 .await
2785 .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2786 report.merge_iteration(next);
2787 iter_done = iter_done.saturating_add(1);
2788 }
2789 if iter_done > 1 {
2790 TerminalReporter::print_progress(&format!(
2791 "Self-test repeated {} iteration(s) ({:.1?} elapsed)",
2792 iter_done,
2793 start.elapsed(),
2794 ));
2795 }
2796 let per_endpoint_summary: Vec<
2806 crate::conformance::per_endpoint_summary::PerEndpointSummary,
2807 >;
2808 if let Some(sink) = capture_sink {
2809 if let Ok(guard) = sink.lock() {
2810 let jsonl_path = self.output.join("conformance-self-test-requests.jsonl");
2811 let mut lines = String::with_capacity(guard.len() * 256);
2812 for entry in guard.iter() {
2813 if let Ok(line) = serde_json::to_string(entry) {
2814 lines.push_str(&line);
2815 lines.push('\n');
2816 }
2817 }
2818 let _ = std::fs::write(&jsonl_path, lines);
2819 let html_path = self.output.join("conformance-self-test-requests.html");
2820 let html =
2821 crate::conformance::capture_html::render_capture_html(guard.as_slice());
2822 let _ = std::fs::write(&html_path, html);
2823
2824 per_endpoint_summary =
2828 crate::conformance::per_endpoint_summary::build_summary(guard.as_slice());
2829 let summary_path = self.output.join("conformance-per-endpoint.json");
2830 if let Ok(json) = serde_json::to_string_pretty(&per_endpoint_summary) {
2831 let _ = std::fs::write(&summary_path, json);
2832 TerminalReporter::print_progress(&format!(
2833 "Self-test request/response capture written to {} ({} entries) + {} + {}",
2834 jsonl_path.display(),
2835 guard.len(),
2836 html_path.display(),
2837 summary_path.display(),
2838 ));
2839 } else {
2840 TerminalReporter::print_progress(&format!(
2841 "Self-test request/response capture written to {} ({} entries) + {}",
2842 jsonl_path.display(),
2843 guard.len(),
2844 html_path.display(),
2845 ));
2846 }
2847 } else {
2848 per_endpoint_summary = Vec::new();
2849 }
2850 } else {
2851 per_endpoint_summary = Vec::new();
2852 }
2853 TerminalReporter::print_progress(&report.render_summary());
2854 if let Some(sink) = network_events_sink {
2861 if let Ok(guard) = sink.lock() {
2862 let path = self.output.join("conformance-network-events.json");
2863 if let Ok(json) = serde_json::to_string_pretty(&*guard) {
2864 let _ = std::fs::write(&path, json);
2865 if guard.is_empty() {
2866 TerminalReporter::print_progress(
2867 "No wire-level network failures during self-test (file written empty)",
2868 );
2869 } else {
2870 TerminalReporter::print_warning(&format!(
2871 "Recorded {} wire-level network event(s) to {}",
2872 guard.len(),
2873 path.display()
2874 ));
2875 }
2876 }
2877 }
2878 }
2879 let json_path = self.output.join("conformance-self-test.json");
2883 if let Ok(json) = serde_json::to_string_pretty(&report) {
2884 let _ = std::fs::write(&json_path, json);
2885 TerminalReporter::print_progress(&format!(
2886 "Self-test report written to {}",
2887 json_path.display()
2888 ));
2889 }
2890 let issues = report.definite_issues();
2894 let issues_path = self.output.join("conformance-definite-issues.json");
2895 if let Ok(json) = serde_json::to_string_pretty(&issues) {
2896 if std::fs::write(&issues_path, json).is_ok() && !issues.is_empty() {
2897 TerminalReporter::print_warning(&format!(
2898 "{} definite issue(s) — see {}",
2899 issues.len(),
2900 issues_path.display()
2901 ));
2902 }
2903 }
2904 let owasp_accepted = report.owasp_accepted_probes();
2907 if !owasp_accepted.is_empty() {
2908 let owasp_path = self.output.join("conformance-owasp-accepted.json");
2909 if let Ok(json) = serde_json::to_string_pretty(&owasp_accepted) {
2910 if std::fs::write(&owasp_path, json).is_ok() {
2911 TerminalReporter::print_warning(&format!(
2912 "{} owasp injection probe(s) accepted by the target — see {}",
2913 owasp_accepted.len(),
2914 owasp_path.display()
2915 ));
2916 }
2917 }
2918 }
2919 if let Some(status) = report.detect_target_misconfiguration() {
2928 let hint = match status {
2929 404 => " Likely cause: spec paths don't match deployed routes — check --base-path and the spec's `servers` block.",
2930 401 | 403 => " Likely cause: authentication header is missing or invalid — check --conformance-header.",
2931 _ => "",
2932 };
2933 TerminalReporter::print_warning(&format!(
2934 "Self-test misconfiguration: every positive case returned {status}.{hint} Negative results below are meaningless under this condition."
2935 ));
2936 } else if !report.all_passed() {
2937 TerminalReporter::print_warning(
2938 "Self-test detected gaps — server let through at least one request that should have been a 4xx",
2939 );
2940 } else {
2941 TerminalReporter::print_success(
2942 "Self-test passed — all positive cases accepted and all negative cases rejected",
2943 );
2944 }
2945 let html_path = self.output.join("conformance-report.html");
2952 let audit_path = self.output.join("conformance-spec-audit.json");
2953 let audit_value = std::fs::read_to_string(&audit_path)
2954 .ok()
2955 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
2956 let render_opts = crate::conformance::report_html::RenderOptions {
2961 missed_cap: match self.report_missed_cap {
2962 Some(0) => None,
2963 Some(n) => Some(n as usize),
2964 None => Some(200),
2965 },
2966 };
2967 let mut html = crate::conformance::report_html::render_html_with_options(
2968 &report,
2969 audit_value.as_ref(),
2970 &render_opts,
2971 );
2972 let summary_section = crate::conformance::per_endpoint_summary::render_html_section(
2978 &per_endpoint_summary,
2979 );
2980 if !summary_section.is_empty() {
2981 if let Some(idx) = html.rfind("</body>") {
2982 html.insert_str(idx, &summary_section);
2983 } else {
2984 html.push_str(&summary_section);
2985 }
2986 }
2987 if std::fs::write(&html_path, html).is_ok() {
2988 TerminalReporter::print_progress(&format!(
2989 "HTML report written to {}",
2990 html_path.display()
2991 ));
2992 }
2993
2994 if self.validate_requests && !self.spec.is_empty() {
3006 let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3007 &self.spec,
3008 &self.output,
3009 self.base_path.as_deref(),
3010 )
3011 .await?;
3012 if n > 0 {
3013 TerminalReporter::print_warning(&format!(
3014 "{} emitted request(s) recorded against the spec — see conformance-request-violations.json",
3015 n
3016 ));
3017 }
3018 }
3019 return Ok(());
3020 }
3021
3022 if self.validate_requests && !self.spec.is_empty() {
3024 TerminalReporter::print_progress("Validating requests against OpenAPI spec...");
3025 let violation_count = crate::conformance::request_validator::run_request_validation(
3026 &self.spec,
3027 self.conformance_custom.as_deref(),
3028 self.base_path.as_deref(),
3029 &self.output,
3030 )
3031 .await?;
3032 if violation_count > 0 {
3033 TerminalReporter::print_warning(&format!(
3034 "{} request validation violation(s) found — see conformance-request-violations.json",
3035 violation_count
3036 ));
3037 } else {
3038 TerminalReporter::print_success("All requests conform to the OpenAPI spec");
3039 }
3040 }
3041
3042 if self.generate_only || self.use_k6 {
3044 let script = if let Some(annotated) = &annotated_ops {
3045 let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
3046 config,
3047 annotated.clone(),
3048 );
3049 let op_count = gen.operation_count();
3050 let (script, check_count) = gen.generate()?;
3051 TerminalReporter::print_success(&format!(
3052 "Conformance: {} operations analyzed, {} unique checks generated",
3053 op_count, check_count
3054 ));
3055 script
3056 } else {
3057 let generator = ConformanceGenerator::new(config);
3058 generator.generate()?
3059 };
3060
3061 let script_path = self.output.join("k6-conformance.js");
3062 std::fs::write(&script_path, &script).map_err(|e| {
3063 BenchError::Other(format!("Failed to write conformance script: {}", e))
3064 })?;
3065 TerminalReporter::print_success(&format!(
3066 "Conformance script generated: {}",
3067 script_path.display()
3068 ));
3069
3070 if self.generate_only {
3071 println!("\nScript generated. Run with:");
3072 println!(" k6 run {}", script_path.display());
3073 return Ok(());
3074 }
3075
3076 if !K6Executor::is_k6_installed() {
3078 TerminalReporter::print_error("k6 is not installed");
3079 TerminalReporter::print_warning(
3080 "Install k6 from: https://k6.io/docs/get-started/installation/",
3081 );
3082 return Err(BenchError::K6NotFound);
3083 }
3084
3085 TerminalReporter::print_progress("Running conformance tests via k6...");
3086 let executor = K6Executor::new()?
3087 .with_local_ips(self.source_ips.join(","))
3088 .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3089 executor.execute(&script_path, Some(&self.output), self.verbose).await?;
3090
3091 let report_path = self.output.join("conformance-report.json");
3092 if report_path.exists() {
3093 let report = ConformanceReport::from_file(&report_path)?;
3094 report.print_report_with_options(self.conformance_all_operations);
3095 self.save_conformance_report(&report, &report_path)?;
3096 } else {
3097 TerminalReporter::print_warning(
3098 "Conformance report not generated (k6 handleSummary may not have run)",
3099 );
3100 }
3101
3102 if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3114 let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3115 &self.spec,
3116 &self.output,
3117 self.base_path.as_deref(),
3118 )
3119 .await?;
3120 if n > 0 {
3121 TerminalReporter::print_warning(&format!(
3122 "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3123 n
3124 ));
3125 }
3126 }
3127
3128 return Ok(());
3129 }
3130
3131 TerminalReporter::print_progress("Running conformance tests (native executor)...");
3133
3134 let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3135
3136 let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3146 executor = if let Some(annotated) = &annotated_ops {
3147 executor.with_spec_driven_checks(annotated)
3148 } else if custom_only {
3149 executor
3150 } else {
3151 executor.with_reference_checks()
3152 };
3153 executor = executor.with_custom_checks()?;
3154
3155 TerminalReporter::print_success(&format!(
3156 "Executing {} conformance checks...",
3157 executor.check_count()
3158 ));
3159
3160 let report = executor.execute().await?;
3161 report.print_report_with_options(self.conformance_all_operations);
3162
3163 let failure_details = report.failure_details();
3165 if !failure_details.is_empty() {
3166 let details_path = self.output.join("conformance-failure-details.json");
3167 if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3168 let _ = std::fs::write(&details_path, json);
3169 TerminalReporter::print_success(&format!(
3170 "Failure details saved to: {}",
3171 details_path.display()
3172 ));
3173 }
3174 }
3175
3176 let report_path = self.output.join("conformance-report.json");
3178 let report_json = serde_json::to_string_pretty(&report.to_json())
3179 .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3180 std::fs::write(&report_path, &report_json)
3181 .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3182 TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
3183
3184 self.save_conformance_report(&report, &report_path)?;
3185
3186 if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3197 let n =
3198 crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3199 &self.spec,
3200 &self.output,
3201 self.base_path.as_deref(),
3202 )
3203 .await?;
3204 if n > 0 {
3205 TerminalReporter::print_warning(&format!(
3206 "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3207 n
3208 ));
3209 }
3210 }
3211
3212 Ok(())
3213 }
3214
3215 fn save_conformance_report(
3217 &self,
3218 report: &crate::conformance::report::ConformanceReport,
3219 report_path: &Path,
3220 ) -> Result<()> {
3221 if self.conformance_report_format == "sarif" {
3222 use crate::conformance::sarif::ConformanceSarifReport;
3223 ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
3224 TerminalReporter::print_success(&format!(
3225 "SARIF report saved to: {}",
3226 self.conformance_report.display()
3227 ));
3228 } else if self.conformance_report != *report_path {
3229 std::fs::copy(report_path, &self.conformance_report)?;
3230 TerminalReporter::print_success(&format!(
3231 "Report saved to: {}",
3232 self.conformance_report.display()
3233 ));
3234 }
3235 Ok(())
3236 }
3237
3238 async fn execute_multi_target_self_test(&self, targets_file: &Path) -> Result<()> {
3250 use crate::conformance::self_test::SelfTestConfig;
3251
3252 TerminalReporter::print_progress("Multi-target conformance self-test mode");
3253 let targets = parse_targets_file(targets_file)?;
3254 if targets.is_empty() {
3255 return Err(BenchError::Other("No targets found in file".to_string()));
3256 }
3257 TerminalReporter::print_success(&format!("Loaded {} target(s)", targets.len()));
3258
3259 let annotated_ops = if !self.spec.is_empty() {
3261 let parser = SpecParser::from_file(&self.spec[0]).await?;
3262 let operations = parser.get_operations();
3263 Some(
3264 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3265 &operations,
3266 parser.spec(),
3267 ),
3268 )
3269 } else {
3270 return Err(BenchError::Other("--conformance-self-test requires --spec".to_string()));
3271 };
3272 let Some(ops) = annotated_ops else {
3273 unreachable!()
3274 };
3275
3276 std::fs::create_dir_all(&self.output)?;
3277 let resolved_base_path = self.base_path.clone();
3278 let target_iterations = self.conformance_self_test_iterations.max(1);
3279 let duration_budget = self
3280 .conformance_self_test_duration
3281 .as_ref()
3282 .map(|s| Self::parse_duration(s))
3283 .transpose()?
3284 .map(std::time::Duration::from_secs);
3285
3286 for (idx, target) in targets.iter().enumerate() {
3287 let target_dir = self.output.join(format!("target_{}", idx));
3288 std::fs::create_dir_all(&target_dir)?;
3289 TerminalReporter::print_progress(&format!(
3290 "[target {}/{}] {}",
3291 idx + 1,
3292 targets.len(),
3293 target.url
3294 ));
3295
3296 let merged_headers: Vec<(String, String)> = self
3297 .conformance_headers
3298 .iter()
3299 .filter_map(|h| {
3300 let (n, v) = h.split_once(':')?;
3301 Some((n.trim().to_string(), v.trim().to_string()))
3302 })
3303 .collect();
3304
3305 let cfg = SelfTestConfig {
3306 target_url: target.url.clone(),
3307 skip_tls_verify: self.skip_tls_verify,
3308 timeout: std::time::Duration::from_secs(30),
3309 extra_headers: merged_headers,
3310 delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
3311 base_path: resolved_base_path.clone(),
3312 source_ips: parse_ip_list(&self.source_ips, "source-ip"),
3313 geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
3314 geo_source_headers: if self.geo_source_headers.is_empty() {
3315 crate::conformance::self_test::default_geo_source_headers()
3316 } else {
3317 self.geo_source_headers.clone()
3318 },
3319 capture: if self.conformance_self_test_capture
3320 || self.validate_response_schemas
3321 || self.validate_requests
3322 {
3323 Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
3327 } else {
3328 None
3329 },
3330 validate_response_schemas: self.validate_response_schemas,
3331 spec_label: self.spec.first().map(|p| {
3332 p.file_name()
3333 .map(|s| s.to_string_lossy().into_owned())
3334 .unwrap_or_else(|| p.to_string_lossy().into_owned())
3335 }),
3336 network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
3337 current_iteration: 1,
3338 };
3339 let capture_sink = cfg.capture.clone();
3340 let network_events_sink = cfg.network_events.clone();
3341
3342 let start = std::time::Instant::now();
3343 let deadline = duration_budget.map(|d| start + d);
3347 let mut cfg = cfg;
3351 cfg.current_iteration = 1;
3352 let mut report =
3353 crate::conformance::self_test::run_self_test_with_deadline(&ops, &cfg, deadline)
3354 .await
3355 .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3356 let mut iter_done: u32 = 1;
3357 loop {
3358 let by_iter = iter_done >= target_iterations;
3359 let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
3360 if by_iter && by_dur {
3361 break;
3362 }
3363 cfg.current_iteration = iter_done.saturating_add(1);
3364 let next = crate::conformance::self_test::run_self_test_with_deadline(
3365 &ops, &cfg, deadline,
3366 )
3367 .await
3368 .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3369 report.merge_iteration(next);
3370 iter_done = iter_done.saturating_add(1);
3371 }
3372 if iter_done > 1 {
3373 TerminalReporter::print_progress(&format!(
3374 " ran {} iteration(s) in {:.1?}",
3375 iter_done,
3376 start.elapsed(),
3377 ));
3378 }
3379
3380 if let Some(sink) = capture_sink {
3382 if let Ok(guard) = sink.lock() {
3383 let jsonl = target_dir.join("conformance-self-test-requests.jsonl");
3384 let mut lines = String::with_capacity(guard.len() * 256);
3385 for entry in guard.iter() {
3386 if let Ok(line) = serde_json::to_string(entry) {
3387 lines.push_str(&line);
3388 lines.push('\n');
3389 }
3390 }
3391 let _ = std::fs::write(&jsonl, lines);
3392 }
3393 }
3394 if let Some(sink) = network_events_sink {
3395 if let Ok(guard) = sink.lock() {
3396 let path = target_dir.join("conformance-network-events.json");
3397 if let Ok(json) = serde_json::to_string_pretty(&*guard) {
3398 let _ = std::fs::write(&path, json);
3399 if !guard.is_empty() {
3400 TerminalReporter::print_warning(&format!(
3401 " recorded {} wire-level network event(s)",
3402 guard.len()
3403 ));
3404 }
3405 }
3406 }
3407 }
3408
3409 let json_path = target_dir.join("conformance-self-test.json");
3410 if let Ok(json) = serde_json::to_string_pretty(&report) {
3411 let _ = std::fs::write(&json_path, json);
3412 }
3413 let issues = report.definite_issues();
3416 if let Ok(json) = serde_json::to_string_pretty(&issues) {
3417 let issues_path = target_dir.join("conformance-definite-issues.json");
3418 if std::fs::write(&issues_path, json).is_ok() && !issues.is_empty() {
3419 TerminalReporter::print_warning(&format!(
3420 " {} definite issue(s) — see {}",
3421 issues.len(),
3422 issues_path.display()
3423 ));
3424 }
3425 }
3426 let owasp_accepted = report.owasp_accepted_probes();
3428 if !owasp_accepted.is_empty() {
3429 if let Ok(json) = serde_json::to_string_pretty(&owasp_accepted) {
3430 let owasp_path = target_dir.join("conformance-owasp-accepted.json");
3431 if std::fs::write(&owasp_path, json).is_ok() {
3432 TerminalReporter::print_warning(&format!(
3433 " {} owasp injection probe(s) accepted by the target — see {}",
3434 owasp_accepted.len(),
3435 owasp_path.display()
3436 ));
3437 }
3438 }
3439 }
3440 TerminalReporter::print_progress(&report.render_summary());
3441
3442 if self.validate_requests {
3451 let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3452 &self.spec,
3453 &target_dir,
3454 self.base_path.as_deref(),
3455 )
3456 .await?;
3457 if n > 0 {
3458 TerminalReporter::print_warning(&format!(
3459 " {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3460 n,
3461 target_dir.display(),
3462 ));
3463 }
3464 }
3465 }
3466
3467 Ok(())
3468 }
3469
3470 async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
3476 use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
3477 use crate::conformance::report::ConformanceReport;
3478 use crate::conformance::spec::ConformanceFeature;
3479
3480 TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
3481
3482 TerminalReporter::print_progress("Parsing targets file...");
3484 let targets = parse_targets_file(targets_file)?;
3485 let num_targets = targets.len();
3486 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
3487
3488 if targets.is_empty() {
3489 return Err(BenchError::Other("No targets found in file".to_string()));
3490 }
3491
3492 TerminalReporter::print_progress(CONFORMANCE_REPLACES_LOAD_ADVISORY);
3493
3494 let categories = self.conformance_categories.as_ref().map(|cats_str| {
3496 cats_str
3497 .split(',')
3498 .filter_map(|s| {
3499 let trimmed = s.trim();
3500 if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
3501 Some(canonical.to_string())
3502 } else {
3503 TerminalReporter::print_warning(&format!(
3504 "Unknown conformance category: '{}'. Valid categories: {}",
3505 trimmed,
3506 ConformanceFeature::cli_category_names()
3507 .iter()
3508 .map(|(cli, _)| *cli)
3509 .collect::<Vec<_>>()
3510 .join(", ")
3511 ));
3512 None
3513 }
3514 })
3515 .collect::<Vec<String>>()
3516 });
3517
3518 let base_custom_headers: Vec<(String, String)> = self
3520 .conformance_headers
3521 .iter()
3522 .filter_map(|h| {
3523 let (name, value) = h.split_once(':')?;
3524 Some((name.trim().to_string(), value.trim().to_string()))
3525 })
3526 .collect();
3527
3528 if !base_custom_headers.is_empty() {
3529 TerminalReporter::print_progress(&format!(
3530 "Using {} base custom header(s) for authentication",
3531 base_custom_headers.len()
3532 ));
3533 }
3534
3535 let annotated_ops = if !self.spec.is_empty() {
3537 TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
3538 let parser = SpecParser::from_file(&self.spec[0]).await?;
3539 let operations = parser.get_operations();
3540 let annotated =
3541 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3542 &operations,
3543 parser.spec(),
3544 );
3545 TerminalReporter::print_success(&format!(
3546 "Analyzed {} operations, found {} feature annotations",
3547 operations.len(),
3548 annotated.iter().map(|a| a.features.len()).sum::<usize>()
3549 ));
3550 Some(annotated)
3551 } else {
3552 None
3553 };
3554
3555 std::fs::create_dir_all(&self.output)?;
3557
3558 struct TargetResult {
3560 url: String,
3561 passed: usize,
3562 failed: usize,
3563 elapsed: std::time::Duration,
3564 report_json: serde_json::Value,
3565 owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
3566 }
3567
3568 let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
3569 let total_start = std::time::Instant::now();
3570
3571 for (idx, target) in targets.iter().enumerate() {
3572 tracing::info!(
3573 "Running conformance tests against target {}/{}: {}",
3574 idx + 1,
3575 num_targets,
3576 target.url
3577 );
3578 TerminalReporter::print_progress(&format!(
3579 "\n--- Target {}/{}: {} ---",
3580 idx + 1,
3581 num_targets,
3582 target.url
3583 ));
3584
3585 let mut merged_headers = base_custom_headers.clone();
3587 if let Some(ref target_headers) = target.headers {
3588 for (name, value) in target_headers {
3589 if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
3591 existing.1 = value.clone();
3592 } else {
3593 merged_headers.push((name.clone(), value.clone()));
3594 }
3595 }
3596 }
3597 if let Some(ref auth) = target.auth {
3599 if let Some(existing) =
3600 merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
3601 {
3602 existing.1 = auth.clone();
3603 } else {
3604 merged_headers.push(("Authorization".to_string(), auth.clone()));
3605 }
3606 }
3607
3608 let target_dir = self.output.join(format!("target_{}", idx));
3614 std::fs::create_dir_all(&target_dir)?;
3615
3616 let config = ConformanceConfig {
3617 target_url: target.url.clone(),
3618 api_key: self.conformance_api_key.clone(),
3619 basic_auth: self.conformance_basic_auth.clone(),
3620 skip_tls_verify: self.skip_tls_verify,
3621 categories: categories.clone(),
3622 base_path: self.base_path.clone(),
3623 custom_headers: merged_headers,
3624 output_dir: Some(target_dir.clone()),
3625 all_operations: self.conformance_all_operations,
3626 custom_checks_file: self.conformance_custom.clone(),
3627 request_delay_ms: self.conformance_delay_ms,
3628 custom_filter: self.conformance_custom_filter.clone(),
3629 export_requests: self.export_requests,
3630 validate_requests: self.validate_requests,
3631 };
3632
3633 let target_start = std::time::Instant::now();
3634 let report = if self.use_k6 {
3635 if !K6Executor::is_k6_installed() {
3636 TerminalReporter::print_error("k6 is not installed");
3637 TerminalReporter::print_warning(
3638 "Install k6 from: https://k6.io/docs/get-started/installation/",
3639 );
3640 return Err(BenchError::K6NotFound);
3641 }
3642
3643 let script = if let Some(ref annotated) = annotated_ops {
3644 let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
3645 config.clone(),
3646 annotated.clone(),
3647 );
3648 let (script, _check_count) = gen.generate()?;
3649 script
3650 } else {
3651 let generator = ConformanceGenerator::new(config.clone());
3652 generator.generate()?
3653 };
3654
3655 let script_path = target_dir.join("k6-conformance.js");
3656 std::fs::write(&script_path, &script).map_err(|e| {
3657 BenchError::Other(format!("Failed to write conformance script: {}", e))
3658 })?;
3659 TerminalReporter::print_success(&format!(
3660 "Conformance script generated: {}",
3661 script_path.display()
3662 ));
3663
3664 TerminalReporter::print_progress(&format!(
3665 "Running conformance tests via k6 against {}...",
3666 target.url
3667 ));
3668 let k6 = K6Executor::new()?
3669 .with_local_ips(self.source_ips.join(","))
3670 .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3671 let api_port = 6565u16.saturating_add(idx as u16);
3673 k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
3674 .await?;
3675
3676 let report_path = target_dir.join("conformance-report.json");
3677 if report_path.exists() {
3678 ConformanceReport::from_file(&report_path)?
3679 } else {
3680 TerminalReporter::print_warning(&format!(
3681 "Conformance report not generated for target {} (k6 handleSummary may not have run)",
3682 target.url
3683 ));
3684 continue;
3685 }
3686 } else {
3687 let mut executor =
3688 crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3689
3690 let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3693 executor = if let Some(ref annotated) = annotated_ops {
3694 executor.with_spec_driven_checks(annotated)
3695 } else if custom_only {
3696 executor
3697 } else {
3698 executor.with_reference_checks()
3699 };
3700 executor = executor.with_custom_checks()?;
3701
3702 TerminalReporter::print_success(&format!(
3703 "Executing {} conformance checks against {}...",
3704 executor.check_count(),
3705 target.url
3706 ));
3707
3708 executor.execute().await?
3709 };
3710 let target_elapsed = target_start.elapsed();
3711
3712 let report_json = report.to_json();
3713
3714 let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
3716 let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
3717 let total_checks = passed + failed;
3718 let rate = if total_checks == 0 {
3719 0.0
3720 } else {
3721 (passed as f64 / total_checks as f64) * 100.0
3722 };
3723
3724 TerminalReporter::print_success(&format!(
3725 "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
3726 target.url,
3727 passed,
3728 total_checks,
3729 rate,
3730 target_elapsed.as_secs_f64()
3731 ));
3732
3733 let target_report_path = target_dir.join("conformance-report.json");
3735 let report_str = serde_json::to_string_pretty(&report_json)
3736 .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3737 std::fs::write(&target_report_path, &report_str)
3738 .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3739
3740 let failure_details = report.failure_details();
3742 if !failure_details.is_empty() {
3743 let details_path = target_dir.join("conformance-failure-details.json");
3744 if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3745 let _ = std::fs::write(&details_path, json);
3746 }
3747 }
3748
3749 if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3756 let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3757 &self.spec,
3758 &target_dir,
3759 self.base_path.as_deref(),
3760 )
3761 .await?;
3762 if n > 0 {
3763 TerminalReporter::print_warning(&format!(
3764 "Target {}: {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3765 target.url,
3766 n,
3767 target_dir.display()
3768 ));
3769 }
3770 }
3771
3772 let owasp_coverage = report.owasp_coverage_data();
3774
3775 target_results.push(TargetResult {
3776 url: target.url.clone(),
3777 passed,
3778 failed,
3779 elapsed: target_elapsed,
3780 report_json,
3781 owasp_coverage,
3782 });
3783 }
3784
3785 let total_elapsed = total_start.elapsed();
3786
3787 println!("\n{}", "=".repeat(80));
3789 println!(" Multi-Target Conformance Summary");
3790 println!("{}", "=".repeat(80));
3791 println!(
3792 " {:<40} {:>8} {:>8} {:>8} {:>8}",
3793 "Target URL", "Passed", "Failed", "Rate", "Time"
3794 );
3795 println!(" {}", "-".repeat(76));
3796
3797 let mut total_passed = 0usize;
3798 let mut total_failed = 0usize;
3799
3800 for result in &target_results {
3801 let total_checks = result.passed + result.failed;
3802 let rate = if total_checks == 0 {
3803 0.0
3804 } else {
3805 (result.passed as f64 / total_checks as f64) * 100.0
3806 };
3807
3808 let display_url = if result.url.len() > 38 {
3810 format!("{}...", &result.url[..35])
3811 } else {
3812 result.url.clone()
3813 };
3814
3815 println!(
3816 " {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3817 display_url,
3818 result.passed,
3819 result.failed,
3820 rate,
3821 result.elapsed.as_secs_f64()
3822 );
3823
3824 total_passed += result.passed;
3825 total_failed += result.failed;
3826 }
3827
3828 let grand_total = total_passed + total_failed;
3829 let overall_rate = if grand_total == 0 {
3830 0.0
3831 } else {
3832 (total_passed as f64 / grand_total as f64) * 100.0
3833 };
3834
3835 println!(" {}", "-".repeat(76));
3836 println!(
3837 " {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3838 format!("TOTAL ({} targets)", num_targets),
3839 total_passed,
3840 total_failed,
3841 overall_rate,
3842 total_elapsed.as_secs_f64()
3843 );
3844 println!("{}", "=".repeat(80));
3845
3846 for result in &target_results {
3848 println!("\n OWASP API Security Top 10 Coverage for {}:", result.url);
3849 for entry in &result.owasp_coverage {
3850 let status = if !entry.tested {
3851 "-"
3852 } else if entry.all_passed {
3853 "pass"
3854 } else {
3855 "FAIL"
3856 };
3857 let via = if entry.via_categories.is_empty() {
3858 String::new()
3859 } else {
3860 format!(" (via {})", entry.via_categories.join(", "))
3861 };
3862 println!(" {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
3863 }
3864 }
3865
3866 let per_target_summaries: Vec<serde_json::Value> = target_results
3868 .iter()
3869 .enumerate()
3870 .map(|(idx, r)| {
3871 let total_checks = r.passed + r.failed;
3872 let rate = if total_checks == 0 {
3873 0.0
3874 } else {
3875 (r.passed as f64 / total_checks as f64) * 100.0
3876 };
3877 let owasp_json: Vec<serde_json::Value> = r
3878 .owasp_coverage
3879 .iter()
3880 .map(|e| {
3881 serde_json::json!({
3882 "id": e.id,
3883 "name": e.name,
3884 "tested": e.tested,
3885 "all_passed": e.all_passed,
3886 "via_categories": e.via_categories,
3887 })
3888 })
3889 .collect();
3890 serde_json::json!({
3891 "target_url": r.url,
3892 "target_index": idx,
3893 "checks_passed": r.passed,
3894 "checks_failed": r.failed,
3895 "total_checks": total_checks,
3896 "pass_rate": rate,
3897 "elapsed_seconds": r.elapsed.as_secs_f64(),
3898 "report": r.report_json,
3899 "owasp_coverage": owasp_json,
3900 })
3901 })
3902 .collect();
3903
3904 let combined_summary = serde_json::json!({
3905 "total_targets": num_targets,
3906 "total_checks_passed": total_passed,
3907 "total_checks_failed": total_failed,
3908 "overall_pass_rate": overall_rate,
3909 "total_elapsed_seconds": total_elapsed.as_secs_f64(),
3910 "targets": per_target_summaries,
3911 });
3912
3913 let summary_path = self.output.join("multi-target-conformance-summary.json");
3914 let summary_str = serde_json::to_string_pretty(&combined_summary)
3915 .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
3916 std::fs::write(&summary_path, &summary_str)
3917 .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
3918 TerminalReporter::print_success(&format!(
3919 "Combined summary saved to: {}",
3920 summary_path.display()
3921 ));
3922
3923 Ok(())
3924 }
3925
3926 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
3928 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
3929
3930 let custom_headers = self.parse_headers()?;
3932
3933 let mut config = OwaspApiConfig::new()
3935 .with_auth_header(&self.owasp_auth_header)
3936 .with_verbose(self.verbose)
3937 .with_insecure(self.skip_tls_verify)
3938 .with_concurrency(self.vus as usize)
3939 .with_iterations(self.owasp_iterations as usize)
3940 .with_base_path(self.base_path.clone())
3941 .with_custom_headers(custom_headers);
3942
3943 if let Some(ref token) = self.owasp_auth_token {
3945 config = config.with_valid_auth_token(token);
3946 }
3947
3948 if let Some(ref cats_str) = self.owasp_categories {
3950 let categories: Vec<OwaspCategory> = cats_str
3951 .split(',')
3952 .filter_map(|s| {
3953 let trimmed = s.trim();
3954 match trimmed.parse::<OwaspCategory>() {
3955 Ok(cat) => Some(cat),
3956 Err(e) => {
3957 TerminalReporter::print_warning(&e);
3958 None
3959 }
3960 }
3961 })
3962 .collect();
3963
3964 if !categories.is_empty() {
3965 config = config.with_categories(categories);
3966 }
3967 }
3968
3969 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
3971 config.admin_paths_file = Some(admin_paths_file.clone());
3972 if let Err(e) = config.load_admin_paths() {
3973 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
3974 }
3975 }
3976
3977 if let Some(ref id_fields_str) = self.owasp_id_fields {
3979 let id_fields: Vec<String> = id_fields_str
3980 .split(',')
3981 .map(|s| s.trim().to_string())
3982 .filter(|s| !s.is_empty())
3983 .collect();
3984 if !id_fields.is_empty() {
3985 config = config.with_id_fields(id_fields);
3986 }
3987 }
3988
3989 if let Some(ref report_path) = self.owasp_report {
3991 config = config.with_report_path(report_path);
3992 }
3993 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
3994 config = config.with_report_format(format);
3995 }
3996
3997 let categories = config.categories_to_test();
3999 TerminalReporter::print_success(&format!(
4000 "Testing {} OWASP categories: {}",
4001 categories.len(),
4002 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
4003 ));
4004
4005 if config.valid_auth_token.is_some() {
4006 TerminalReporter::print_progress("Using provided auth token for baseline requests");
4007 }
4008
4009 TerminalReporter::print_progress("Generating OWASP security test script...");
4011 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
4012
4013 let script = generator.generate()?;
4015 TerminalReporter::print_success("OWASP security test script generated");
4016
4017 let script_path = if let Some(output) = &self.script_output {
4019 output.clone()
4020 } else {
4021 self.output.join("k6-owasp-security-test.js")
4022 };
4023
4024 if let Some(parent) = script_path.parent() {
4025 std::fs::create_dir_all(parent)?;
4026 }
4027 std::fs::write(&script_path, &script)?;
4028 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
4029
4030 if self.generate_only {
4032 println!("\nOWASP security test script generated. Run it with:");
4033 println!(" k6 run {}", script_path.display());
4034 return Ok(());
4035 }
4036
4037 TerminalReporter::print_progress("Executing OWASP security tests...");
4039 let executor = K6Executor::new()?
4040 .with_local_ips(self.source_ips.join(","))
4041 .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
4042 std::fs::create_dir_all(&self.output)?;
4043
4044 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
4045
4046 let duration_secs = Self::parse_duration(&self.duration)?;
4047 TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
4048
4049 println!("\nOWASP security test results saved to: {}", self.output.display());
4050
4051 Ok(())
4052 }
4053}
4054
4055#[cfg(test)]
4056mod tests {
4057 use super::*;
4058 use tempfile::tempdir;
4059
4060 #[test]
4061 fn test_parse_duration() {
4062 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
4063 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
4064 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
4065 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
4066 }
4067
4068 #[test]
4072 fn parse_ip_list_ipv4_range_inclusive() {
4073 let v = parse_ip_list(&["10.0.0.5-10.0.0.27".into()], "source-ip");
4074 assert_eq!(v.len(), 23);
4075 assert_eq!(v.first().unwrap().to_string(), "10.0.0.5");
4076 assert_eq!(v.last().unwrap().to_string(), "10.0.0.27");
4077 }
4078
4079 #[test]
4082 fn parse_ip_list_range_rejects_backwards() {
4083 let v = parse_ip_list(&["10.0.0.10-10.0.0.5".into()], "source-ip");
4084 assert!(v.is_empty(), "backwards range should produce no IPs; got {v:?}");
4085 }
4086
4087 #[test]
4091 fn parse_ip_list_rejects_ipv6_range_syntax() {
4092 let v = parse_ip_list(&["2001:db8::1-2001:db8::5".into()], "geo-source-ip");
4093 assert!(v.is_empty(), "IPv6 range should be rejected; got {v:?}");
4094 }
4095
4096 #[test]
4098 fn parse_ip_list_range_capped_at_256() {
4099 let v = parse_ip_list(&["10.0.0.0-10.0.5.0".into()], "source-ip");
4100 assert_eq!(v.len(), 256);
4101 assert_eq!(v.first().unwrap().to_string(), "10.0.0.0");
4102 }
4103
4104 #[test]
4107 fn parse_ip_list_plain_and_comma() {
4108 let v = parse_ip_list(&["10.0.0.5".into(), "10.0.0.6,10.0.0.7".into()], "source-ip");
4109 assert_eq!(v.len(), 3);
4110 assert_eq!(v[0].to_string(), "10.0.0.5");
4111 assert_eq!(v[2].to_string(), "10.0.0.7");
4112 }
4113
4114 #[test]
4117 fn parse_ip_list_ipv4_cidr_29_expands_to_8() {
4118 let v = parse_ip_list(&["10.0.0.0/29".into()], "source-ip");
4119 assert_eq!(v.len(), 8);
4120 assert_eq!(v[0].to_string(), "10.0.0.0");
4121 assert_eq!(v[7].to_string(), "10.0.0.7");
4122 }
4123
4124 #[test]
4127 fn parse_ip_list_ipv4_cidr_8_capped_at_256() {
4128 let v = parse_ip_list(&["10.0.0.0/8".into()], "source-ip");
4129 assert_eq!(v.len(), 256);
4130 assert_eq!(v[0].to_string(), "10.0.0.0");
4131 assert_eq!(v[255].to_string(), "10.0.0.255");
4132 }
4133
4134 #[test]
4136 fn parse_ip_list_ipv6_cidr_126_expands_to_4() {
4137 let v = parse_ip_list(&["2001:db8::/126".into()], "geo-source-ip");
4138 assert_eq!(v.len(), 4);
4139 assert!(v[0].is_ipv6());
4140 assert_eq!(v[0].to_string(), "2001:db8::");
4141 assert_eq!(v[3].to_string(), "2001:db8::3");
4142 }
4143
4144 #[test]
4146 fn parse_ip_list_mixed_v4_v6_cidr() {
4147 let v = parse_ip_list(&["10.0.0.0/30,2001:db8::1,203.0.113.42".into()], "geo-source-ip");
4148 assert_eq!(v.len(), 6); assert!(v.iter().any(|ip| ip.to_string() == "2001:db8::1"));
4150 assert!(v.iter().any(|ip| ip.to_string() == "203.0.113.42"));
4151 }
4152
4153 #[test]
4156 fn parse_ip_list_skips_malformed() {
4157 let v = parse_ip_list(
4158 &[
4159 "10.0.0.5".into(),
4160 "not-an-ip".into(),
4161 "10.0.0.6".into(),
4162 "/24".into(),
4163 "1.2.3.4/200".into(),
4164 ],
4165 "source-ip",
4166 );
4167 assert_eq!(v.len(), 2);
4168 assert_eq!(v[0].to_string(), "10.0.0.5");
4169 assert_eq!(v[1].to_string(), "10.0.0.6");
4170 }
4171
4172 #[test]
4173 fn test_parse_duration_invalid() {
4174 assert!(BenchCommand::parse_duration("invalid").is_err());
4175 assert!(BenchCommand::parse_duration("30x").is_err());
4176 }
4177
4178 #[test]
4179 fn test_parse_headers() {
4180 let cmd = BenchCommand {
4181 spec: vec![PathBuf::from("test.yaml")],
4182 spec_dir: None,
4183 merge_conflicts: "error".to_string(),
4184 spec_mode: "merge".to_string(),
4185 dependency_config: None,
4186 target: "http://localhost".to_string(),
4187 base_path: None,
4188 duration: "1m".to_string(),
4189 vus: 10,
4190 scenario: "ramp-up".to_string(),
4191 operations: None,
4192 exclude_operations: None,
4193 auth: None,
4194 headers: vec![
4195 "X-API-Key:test123".to_string(),
4196 "X-Client-ID:client456".to_string(),
4197 ],
4198 output: PathBuf::from("output"),
4199 generate_only: false,
4200 script_output: None,
4201 threshold_percentile: "p(95)".to_string(),
4202 threshold_ms: 500,
4203 max_error_rate: 0.05,
4204 abort_on_error: true,
4205 abort_on_error_rate: 0.95,
4206 verbose: false,
4207 skip_tls_verify: false,
4208 chunked_request_bodies: false,
4209 target_rps: None,
4210 no_keep_alive: false,
4211 targets_file: None,
4212 max_concurrency: None,
4213 results_format: "both".to_string(),
4214 params_file: None,
4215 crud_flow: false,
4216 flow_config: None,
4217 extract_fields: None,
4218 parallel_create: None,
4219 data_file: None,
4220 data_distribution: "unique-per-vu".to_string(),
4221 data_mappings: None,
4222 per_uri_control: false,
4223 error_rate: None,
4224 error_types: None,
4225 security_test: false,
4226 security_payloads: None,
4227 security_categories: None,
4228 security_target_fields: None,
4229 wafbench_dir: None,
4230 wafbench_cycle_all: false,
4231 owasp_api_top10: false,
4232 owasp_categories: None,
4233 owasp_auth_header: "Authorization".to_string(),
4234 owasp_auth_token: None,
4235 owasp_admin_paths: None,
4236 owasp_id_fields: None,
4237 owasp_report: None,
4238 owasp_report_format: "json".to_string(),
4239 owasp_iterations: 1,
4240 conformance: false,
4241 conformance_api_key: None,
4242 conformance_basic_auth: None,
4243 conformance_report: PathBuf::from("conformance-report.json"),
4244 conformance_categories: None,
4245 conformance_report_format: "json".to_string(),
4246 conformance_headers: vec![],
4247 conformance_all_operations: false,
4248 conformance_custom: None,
4249 conformance_delay_ms: 0,
4250 use_k6: false,
4251 conformance_custom_filter: None,
4252 export_requests: false,
4253 validate_requests: false,
4254 conformance_self_test: false,
4255 conformance_self_test_capture: false,
4256 conformance_self_test_iterations: 1,
4257 conformance_self_test_duration: None,
4258 validate_response_schemas: false,
4259 source_ips: Vec::new(),
4260 geo_source_ips: Vec::new(),
4261 geo_source_headers: Vec::new(),
4262 report_missed_cap: None,
4263 discard_response_bodies: false,
4264 dns_policy: None,
4265 };
4266
4267 let headers = cmd.parse_headers().unwrap();
4268 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
4269 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
4270 }
4271
4272 #[test]
4273 fn test_parse_header_string_preserves_comma_in_value() {
4274 let inputs = vec![
4277 "Cookie:session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string(),
4278 "X-Trace:1".to_string(),
4279 ];
4280 let headers = parse_header_string(&inputs).unwrap();
4281 assert_eq!(
4282 headers.get("Cookie"),
4283 Some(&"session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string())
4284 );
4285 assert_eq!(headers.get("X-Trace"), Some(&"1".to_string()));
4286 }
4287
4288 #[test]
4296 fn conformance_advisory_names_every_discarded_flag() {
4297 let msg = CONFORMANCE_REPLACES_LOAD_ADVISORY;
4298 for flag in ["--vus", "--rps", "-d"] {
4299 assert!(
4300 msg.contains(flag),
4301 "conformance advisory must name `{flag}` as ignored; it is discarded on that \
4302 path and silently dropping it is how users end up tuning a knob that does \
4303 nothing (#980). Message was: {msg}"
4304 );
4305 }
4306 assert!(
4307 msg.contains("REPLACES"),
4308 "conformance advisory must say the load run is REPLACED, not merely that some \
4309 flags are ignored — `--conformance` returns before the load path runs, so no \
4310 load traffic is generated at all (#980). Message was: {msg}"
4311 );
4312 }
4313
4314 #[test]
4328 fn multi_target_clone_preserves_fields_parse_headers_reads() {
4329 let src = include_str!("command.rs");
4330
4331 let fn_start = src
4332 .find("async fn execute_multi_target(")
4333 .expect("execute_multi_target should exist");
4334 let block_start = src[fn_start..]
4335 .find("ParallelExecutor::new(")
4336 .map(|i| i + fn_start)
4337 .expect("multi-target path should build a ParallelExecutor");
4338 let block_end = src[block_start..]
4340 .find("\n );")
4341 .map(|i| i + block_start)
4342 .expect("ParallelExecutor::new(..) should be closed");
4343 let block = &src[block_start..block_end];
4344
4345 for field in ["conformance_basic_auth", "conformance_headers"] {
4348 for zeroed in [format!("{field}: None"), format!("{field}: vec![]")] {
4349 assert!(
4350 !block.contains(&zeroed),
4351 "execute_multi_target zeroes `{zeroed}`. parse_headers() folds `{field}` \
4352 into the header map, so zeroing it here strips auth from every \
4353 multi-target run while single-target keeps working (#79 round 64)."
4354 );
4355 }
4356 let passthrough = format!("{field}: self.{field}.clone()");
4357 assert!(
4358 block.contains(&passthrough),
4359 "execute_multi_target must carry `{field}` through as `{passthrough}` so \
4360 parse_headers() can fold it (#79 round 64)."
4361 );
4362 }
4363 }
4364
4365 #[test]
4366 fn test_get_spec_display_name() {
4367 let cmd = BenchCommand {
4368 spec: vec![PathBuf::from("test.yaml")],
4369 spec_dir: None,
4370 merge_conflicts: "error".to_string(),
4371 spec_mode: "merge".to_string(),
4372 dependency_config: None,
4373 target: "http://localhost".to_string(),
4374 base_path: None,
4375 duration: "1m".to_string(),
4376 vus: 10,
4377 scenario: "ramp-up".to_string(),
4378 operations: None,
4379 exclude_operations: None,
4380 auth: None,
4381 headers: Vec::new(),
4382 output: PathBuf::from("output"),
4383 generate_only: false,
4384 script_output: None,
4385 threshold_percentile: "p(95)".to_string(),
4386 threshold_ms: 500,
4387 max_error_rate: 0.05,
4388 abort_on_error: true,
4389 abort_on_error_rate: 0.95,
4390 verbose: false,
4391 skip_tls_verify: false,
4392 chunked_request_bodies: false,
4393 target_rps: None,
4394 no_keep_alive: false,
4395 targets_file: None,
4396 max_concurrency: None,
4397 results_format: "both".to_string(),
4398 params_file: None,
4399 crud_flow: false,
4400 flow_config: None,
4401 extract_fields: None,
4402 parallel_create: None,
4403 data_file: None,
4404 data_distribution: "unique-per-vu".to_string(),
4405 data_mappings: None,
4406 per_uri_control: false,
4407 error_rate: None,
4408 error_types: None,
4409 security_test: false,
4410 security_payloads: None,
4411 security_categories: None,
4412 security_target_fields: None,
4413 wafbench_dir: None,
4414 wafbench_cycle_all: false,
4415 owasp_api_top10: false,
4416 owasp_categories: None,
4417 owasp_auth_header: "Authorization".to_string(),
4418 owasp_auth_token: None,
4419 owasp_admin_paths: None,
4420 owasp_id_fields: None,
4421 owasp_report: None,
4422 owasp_report_format: "json".to_string(),
4423 owasp_iterations: 1,
4424 conformance: false,
4425 conformance_api_key: None,
4426 conformance_basic_auth: None,
4427 conformance_report: PathBuf::from("conformance-report.json"),
4428 conformance_categories: None,
4429 conformance_report_format: "json".to_string(),
4430 conformance_headers: vec![],
4431 conformance_all_operations: false,
4432 conformance_custom: None,
4433 conformance_delay_ms: 0,
4434 use_k6: false,
4435 conformance_custom_filter: None,
4436 export_requests: false,
4437 validate_requests: false,
4438 conformance_self_test: false,
4439 conformance_self_test_capture: false,
4440 conformance_self_test_iterations: 1,
4441 conformance_self_test_duration: None,
4442 validate_response_schemas: false,
4443 source_ips: Vec::new(),
4444 geo_source_ips: Vec::new(),
4445 geo_source_headers: Vec::new(),
4446 report_missed_cap: None,
4447 discard_response_bodies: false,
4448 dns_policy: None,
4449 };
4450
4451 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
4452
4453 let cmd_multi = BenchCommand {
4455 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
4456 spec_dir: None,
4457 merge_conflicts: "error".to_string(),
4458 spec_mode: "merge".to_string(),
4459 dependency_config: None,
4460 target: "http://localhost".to_string(),
4461 base_path: None,
4462 duration: "1m".to_string(),
4463 vus: 10,
4464 scenario: "ramp-up".to_string(),
4465 operations: None,
4466 exclude_operations: None,
4467 auth: None,
4468 headers: Vec::new(),
4469 output: PathBuf::from("output"),
4470 generate_only: false,
4471 script_output: None,
4472 threshold_percentile: "p(95)".to_string(),
4473 threshold_ms: 500,
4474 max_error_rate: 0.05,
4475 abort_on_error: true,
4476 abort_on_error_rate: 0.95,
4477 verbose: false,
4478 skip_tls_verify: false,
4479 chunked_request_bodies: false,
4480 target_rps: None,
4481 no_keep_alive: false,
4482 targets_file: None,
4483 max_concurrency: None,
4484 results_format: "both".to_string(),
4485 params_file: None,
4486 crud_flow: false,
4487 flow_config: None,
4488 extract_fields: None,
4489 parallel_create: None,
4490 data_file: None,
4491 data_distribution: "unique-per-vu".to_string(),
4492 data_mappings: None,
4493 per_uri_control: false,
4494 error_rate: None,
4495 error_types: None,
4496 security_test: false,
4497 security_payloads: None,
4498 security_categories: None,
4499 security_target_fields: None,
4500 wafbench_dir: None,
4501 wafbench_cycle_all: false,
4502 owasp_api_top10: false,
4503 owasp_categories: None,
4504 owasp_auth_header: "Authorization".to_string(),
4505 owasp_auth_token: None,
4506 owasp_admin_paths: None,
4507 owasp_id_fields: None,
4508 owasp_report: None,
4509 owasp_report_format: "json".to_string(),
4510 owasp_iterations: 1,
4511 conformance: false,
4512 conformance_api_key: None,
4513 conformance_basic_auth: None,
4514 conformance_report: PathBuf::from("conformance-report.json"),
4515 conformance_categories: None,
4516 conformance_report_format: "json".to_string(),
4517 conformance_headers: vec![],
4518 conformance_all_operations: false,
4519 conformance_custom: None,
4520 conformance_delay_ms: 0,
4521 use_k6: false,
4522 conformance_custom_filter: None,
4523 export_requests: false,
4524 validate_requests: false,
4525 conformance_self_test: false,
4526 conformance_self_test_capture: false,
4527 conformance_self_test_iterations: 1,
4528 conformance_self_test_duration: None,
4529 validate_response_schemas: false,
4530 source_ips: Vec::new(),
4531 geo_source_ips: Vec::new(),
4532 geo_source_headers: Vec::new(),
4533 report_missed_cap: None,
4534 discard_response_bodies: false,
4535 dns_policy: None,
4536 };
4537
4538 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
4539 }
4540
4541 #[test]
4542 fn test_parse_extracted_values_from_output_dir() {
4543 let dir = tempdir().unwrap();
4544 let path = dir.path().join("extracted_values.json");
4545 std::fs::write(
4546 &path,
4547 r#"{
4548 "pool_id": "abc123",
4549 "count": 0,
4550 "enabled": false,
4551 "metadata": { "owner": "team-a" }
4552}"#,
4553 )
4554 .unwrap();
4555
4556 let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4557 assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
4558 assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
4559 assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
4560 assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
4561 }
4562
4563 #[test]
4564 fn test_parse_extracted_values_missing_file() {
4565 let dir = tempdir().unwrap();
4566 let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4567 assert!(extracted.values.is_empty());
4568 }
4569}