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_core::openapi::multi_spec::{
30 load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_core::openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::PathBuf;
35use std::str::FromStr;
36
37pub struct BenchCommand {
39 pub spec: Vec<PathBuf>,
41 pub spec_dir: Option<PathBuf>,
43 pub merge_conflicts: String,
45 pub spec_mode: String,
47 pub dependency_config: Option<PathBuf>,
49 pub target: String,
50 pub base_path: Option<String>,
53 pub duration: String,
54 pub vus: u32,
55 pub scenario: String,
56 pub operations: Option<String>,
57 pub exclude_operations: Option<String>,
61 pub auth: Option<String>,
62 pub headers: Option<String>,
63 pub output: PathBuf,
64 pub generate_only: bool,
65 pub script_output: Option<PathBuf>,
66 pub threshold_percentile: String,
67 pub threshold_ms: u64,
68 pub max_error_rate: f64,
69 pub verbose: bool,
70 pub skip_tls_verify: bool,
71 pub targets_file: Option<PathBuf>,
73 pub max_concurrency: Option<u32>,
75 pub results_format: String,
77 pub params_file: Option<PathBuf>,
82
83 pub crud_flow: bool,
86 pub flow_config: Option<PathBuf>,
88 pub extract_fields: Option<String>,
90
91 pub parallel_create: Option<u32>,
94
95 pub data_file: Option<PathBuf>,
98 pub data_distribution: String,
100 pub data_mappings: Option<String>,
102 pub per_uri_control: bool,
104
105 pub error_rate: Option<f64>,
108 pub error_types: Option<String>,
110
111 pub security_test: bool,
114 pub security_payloads: Option<PathBuf>,
116 pub security_categories: Option<String>,
118 pub security_target_fields: Option<String>,
120
121 pub wafbench_dir: Option<String>,
124 pub wafbench_cycle_all: bool,
126
127 pub owasp_api_top10: bool,
130 pub owasp_categories: Option<String>,
132 pub owasp_auth_header: String,
134 pub owasp_auth_token: Option<String>,
136 pub owasp_admin_paths: Option<PathBuf>,
138 pub owasp_id_fields: Option<String>,
140 pub owasp_report: Option<PathBuf>,
142 pub owasp_report_format: String,
144 pub owasp_iterations: u32,
146}
147
148impl BenchCommand {
149 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
151 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
152
153 if !self.spec.is_empty() {
155 let specs = load_specs_from_files(self.spec.clone())
156 .await
157 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
158 all_specs.extend(specs);
159 }
160
161 if let Some(spec_dir) = &self.spec_dir {
163 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
164 BenchError::Other(format!("Failed to load specs from directory: {}", e))
165 })?;
166 all_specs.extend(dir_specs);
167 }
168
169 if all_specs.is_empty() {
170 return Err(BenchError::Other(
171 "No spec files provided. Use --spec or --spec-dir.".to_string(),
172 ));
173 }
174
175 if all_specs.len() == 1 {
177 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
179 }
180
181 let conflict_strategy = match self.merge_conflicts.as_str() {
183 "first" => ConflictStrategy::First,
184 "last" => ConflictStrategy::Last,
185 _ => ConflictStrategy::Error,
186 };
187
188 merge_specs(all_specs, conflict_strategy)
189 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
190 }
191
192 fn get_spec_display_name(&self) -> String {
194 if self.spec.len() == 1 {
195 self.spec[0].to_string_lossy().to_string()
196 } else if !self.spec.is_empty() {
197 format!("{} spec files", self.spec.len())
198 } else if let Some(dir) = &self.spec_dir {
199 format!("specs from {}", dir.display())
200 } else {
201 "no specs".to_string()
202 }
203 }
204
205 pub async fn execute(&self) -> Result<()> {
207 if let Some(targets_file) = &self.targets_file {
209 return self.execute_multi_target(targets_file).await;
210 }
211
212 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
214 return self.execute_sequential_specs().await;
215 }
216
217 TerminalReporter::print_header(
220 &self.get_spec_display_name(),
221 &self.target,
222 0, &self.scenario,
224 Self::parse_duration(&self.duration)?,
225 );
226
227 if !K6Executor::is_k6_installed() {
229 TerminalReporter::print_error("k6 is not installed");
230 TerminalReporter::print_warning(
231 "Install k6 from: https://k6.io/docs/get-started/installation/",
232 );
233 return Err(BenchError::K6NotFound);
234 }
235
236 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
238 let merged_spec = self.load_and_merge_specs().await?;
239 let parser = SpecParser::from_spec(merged_spec);
240 if self.spec.len() > 1 || self.spec_dir.is_some() {
241 TerminalReporter::print_success(&format!(
242 "Loaded and merged {} specification(s)",
243 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
244 ));
245 } else {
246 TerminalReporter::print_success("Specification loaded");
247 }
248
249 let mock_config = self.build_mock_config().await;
251 if mock_config.is_mock_server {
252 TerminalReporter::print_progress("Mock server integration enabled");
253 }
254
255 if self.crud_flow {
257 return self.execute_crud_flow(&parser).await;
258 }
259
260 if self.owasp_api_top10 {
262 return self.execute_owasp_test(&parser).await;
263 }
264
265 TerminalReporter::print_progress("Extracting API operations...");
267 let mut operations = if let Some(filter) = &self.operations {
268 parser.filter_operations(filter)?
269 } else {
270 parser.get_operations()
271 };
272
273 if let Some(exclude) = &self.exclude_operations {
275 let before_count = operations.len();
276 operations = parser.exclude_operations(operations, exclude)?;
277 let excluded_count = before_count - operations.len();
278 if excluded_count > 0 {
279 TerminalReporter::print_progress(&format!(
280 "Excluded {} operations matching '{}'",
281 excluded_count, exclude
282 ));
283 }
284 }
285
286 if operations.is_empty() {
287 return Err(BenchError::Other("No operations found in spec".to_string()));
288 }
289
290 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
291
292 let param_overrides = if let Some(params_file) = &self.params_file {
294 TerminalReporter::print_progress("Loading parameter overrides...");
295 let overrides = ParameterOverrides::from_file(params_file)?;
296 TerminalReporter::print_success(&format!(
297 "Loaded parameter overrides ({} operation-specific, {} defaults)",
298 overrides.operations.len(),
299 if overrides.defaults.is_empty() { 0 } else { 1 }
300 ));
301 Some(overrides)
302 } else {
303 None
304 };
305
306 TerminalReporter::print_progress("Generating request templates...");
308 let templates: Vec<_> = operations
309 .iter()
310 .map(|op| {
311 let op_overrides = param_overrides.as_ref().map(|po| {
312 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
313 });
314 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
315 })
316 .collect::<Result<Vec<_>>>()?;
317 TerminalReporter::print_success("Request templates generated");
318
319 let custom_headers = self.parse_headers()?;
321
322 let base_path = self.resolve_base_path(&parser);
324 if let Some(ref bp) = base_path {
325 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
326 }
327
328 TerminalReporter::print_progress("Generating k6 load test script...");
330 let scenario =
331 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
332
333 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
334
335 let k6_config = K6Config {
336 target_url: self.target.clone(),
337 base_path,
338 scenario,
339 duration_secs: Self::parse_duration(&self.duration)?,
340 max_vus: self.vus,
341 threshold_percentile: self.threshold_percentile.clone(),
342 threshold_ms: self.threshold_ms,
343 max_error_rate: self.max_error_rate,
344 auth_header: self.auth.clone(),
345 custom_headers,
346 skip_tls_verify: self.skip_tls_verify,
347 security_testing_enabled,
348 };
349
350 let generator = K6ScriptGenerator::new(k6_config, templates);
351 let mut script = generator.generate()?;
352 TerminalReporter::print_success("k6 script generated");
353
354 let has_advanced_features = self.data_file.is_some()
356 || self.error_rate.is_some()
357 || self.security_test
358 || self.parallel_create.is_some()
359 || self.wafbench_dir.is_some();
360
361 if has_advanced_features {
363 script = self.generate_enhanced_script(&script)?;
364 }
365
366 if mock_config.is_mock_server {
368 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
369 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
370 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
371
372 if let Some(import_end) = script.find("export const options") {
374 script.insert_str(
375 import_end,
376 &format!(
377 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
378 helper_code, setup_code, teardown_code
379 ),
380 );
381 }
382 }
383
384 TerminalReporter::print_progress("Validating k6 script...");
386 let validation_errors = K6ScriptGenerator::validate_script(&script);
387 if !validation_errors.is_empty() {
388 TerminalReporter::print_error("Script validation failed");
389 for error in &validation_errors {
390 eprintln!(" {}", error);
391 }
392 return Err(BenchError::Other(format!(
393 "Generated k6 script has {} validation error(s). Please check the output above.",
394 validation_errors.len()
395 )));
396 }
397 TerminalReporter::print_success("Script validation passed");
398
399 let script_path = if let Some(output) = &self.script_output {
401 output.clone()
402 } else {
403 self.output.join("k6-script.js")
404 };
405
406 if let Some(parent) = script_path.parent() {
407 std::fs::create_dir_all(parent)?;
408 }
409 std::fs::write(&script_path, &script)?;
410 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
411
412 if self.generate_only {
414 println!("\nScript generated successfully. Run it with:");
415 println!(" k6 run {}", script_path.display());
416 return Ok(());
417 }
418
419 TerminalReporter::print_progress("Executing load test...");
421 let executor = K6Executor::new()?;
422
423 std::fs::create_dir_all(&self.output)?;
424
425 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
426
427 let duration_secs = Self::parse_duration(&self.duration)?;
429 TerminalReporter::print_summary(&results, duration_secs);
430
431 println!("\nResults saved to: {}", self.output.display());
432
433 Ok(())
434 }
435
436 async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
438 TerminalReporter::print_progress("Parsing targets file...");
439 let targets = parse_targets_file(targets_file)?;
440 let num_targets = targets.len();
441 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
442
443 if targets.is_empty() {
444 return Err(BenchError::Other("No targets found in file".to_string()));
445 }
446
447 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
449 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
453 &self.get_spec_display_name(),
454 &format!("{} targets", num_targets),
455 0,
456 &self.scenario,
457 Self::parse_duration(&self.duration)?,
458 );
459
460 let executor = ParallelExecutor::new(
462 BenchCommand {
463 spec: self.spec.clone(),
465 spec_dir: self.spec_dir.clone(),
466 merge_conflicts: self.merge_conflicts.clone(),
467 spec_mode: self.spec_mode.clone(),
468 dependency_config: self.dependency_config.clone(),
469 target: self.target.clone(), base_path: self.base_path.clone(),
471 duration: self.duration.clone(),
472 vus: self.vus,
473 scenario: self.scenario.clone(),
474 operations: self.operations.clone(),
475 exclude_operations: self.exclude_operations.clone(),
476 auth: self.auth.clone(),
477 headers: self.headers.clone(),
478 output: self.output.clone(),
479 generate_only: self.generate_only,
480 script_output: self.script_output.clone(),
481 threshold_percentile: self.threshold_percentile.clone(),
482 threshold_ms: self.threshold_ms,
483 max_error_rate: self.max_error_rate,
484 verbose: self.verbose,
485 skip_tls_verify: self.skip_tls_verify,
486 targets_file: None,
487 max_concurrency: None,
488 results_format: self.results_format.clone(),
489 params_file: self.params_file.clone(),
490 crud_flow: self.crud_flow,
491 flow_config: self.flow_config.clone(),
492 extract_fields: self.extract_fields.clone(),
493 parallel_create: self.parallel_create,
494 data_file: self.data_file.clone(),
495 data_distribution: self.data_distribution.clone(),
496 data_mappings: self.data_mappings.clone(),
497 per_uri_control: self.per_uri_control,
498 error_rate: self.error_rate,
499 error_types: self.error_types.clone(),
500 security_test: self.security_test,
501 security_payloads: self.security_payloads.clone(),
502 security_categories: self.security_categories.clone(),
503 security_target_fields: self.security_target_fields.clone(),
504 wafbench_dir: self.wafbench_dir.clone(),
505 wafbench_cycle_all: self.wafbench_cycle_all,
506 owasp_api_top10: self.owasp_api_top10,
507 owasp_categories: self.owasp_categories.clone(),
508 owasp_auth_header: self.owasp_auth_header.clone(),
509 owasp_auth_token: self.owasp_auth_token.clone(),
510 owasp_admin_paths: self.owasp_admin_paths.clone(),
511 owasp_id_fields: self.owasp_id_fields.clone(),
512 owasp_report: self.owasp_report.clone(),
513 owasp_report_format: self.owasp_report_format.clone(),
514 owasp_iterations: self.owasp_iterations,
515 },
516 targets,
517 max_concurrency,
518 );
519
520 let aggregated_results = executor.execute_all().await?;
522
523 self.report_multi_target_results(&aggregated_results)?;
525
526 Ok(())
527 }
528
529 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
531 TerminalReporter::print_multi_target_summary(results);
533
534 if self.results_format == "aggregated" || self.results_format == "both" {
536 let summary_path = self.output.join("aggregated_summary.json");
537 let summary_json = serde_json::json!({
538 "total_targets": results.total_targets,
539 "successful_targets": results.successful_targets,
540 "failed_targets": results.failed_targets,
541 "aggregated_metrics": {
542 "total_requests": results.aggregated_metrics.total_requests,
543 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
544 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
545 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
546 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
547 "error_rate": results.aggregated_metrics.error_rate,
548 },
549 "target_results": results.target_results.iter().map(|r| {
550 serde_json::json!({
551 "target_url": r.target_url,
552 "target_index": r.target_index,
553 "success": r.success,
554 "error": r.error,
555 "total_requests": r.results.total_requests,
556 "failed_requests": r.results.failed_requests,
557 "avg_duration_ms": r.results.avg_duration_ms,
558 "p95_duration_ms": r.results.p95_duration_ms,
559 "p99_duration_ms": r.results.p99_duration_ms,
560 "output_dir": r.output_dir.to_string_lossy(),
561 })
562 }).collect::<Vec<_>>(),
563 });
564
565 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
566 TerminalReporter::print_success(&format!(
567 "Aggregated summary saved to: {}",
568 summary_path.display()
569 ));
570 }
571
572 println!("\nResults saved to: {}", self.output.display());
573 println!(" - Per-target results: {}", self.output.join("target_*").display());
574 if self.results_format == "aggregated" || self.results_format == "both" {
575 println!(
576 " - Aggregated summary: {}",
577 self.output.join("aggregated_summary.json").display()
578 );
579 }
580
581 Ok(())
582 }
583
584 pub fn parse_duration(duration: &str) -> Result<u64> {
586 let duration = duration.trim();
587
588 if let Some(secs) = duration.strip_suffix('s') {
589 secs.parse::<u64>()
590 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
591 } else if let Some(mins) = duration.strip_suffix('m') {
592 mins.parse::<u64>()
593 .map(|m| m * 60)
594 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
595 } else if let Some(hours) = duration.strip_suffix('h') {
596 hours
597 .parse::<u64>()
598 .map(|h| h * 3600)
599 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
600 } else {
601 duration
603 .parse::<u64>()
604 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
605 }
606 }
607
608 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
610 let mut headers = HashMap::new();
611
612 if let Some(header_str) = &self.headers {
613 for pair in header_str.split(',') {
614 let parts: Vec<&str> = pair.splitn(2, ':').collect();
615 if parts.len() != 2 {
616 return Err(BenchError::Other(format!(
617 "Invalid header format: '{}'. Expected 'Key:Value'",
618 pair
619 )));
620 }
621 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
622 }
623 }
624
625 Ok(headers)
626 }
627
628 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
637 if let Some(cli_base_path) = &self.base_path {
639 if cli_base_path.is_empty() {
640 return None;
642 }
643 return Some(cli_base_path.clone());
644 }
645
646 parser.get_base_path()
648 }
649
650 async fn build_mock_config(&self) -> MockIntegrationConfig {
652 if MockServerDetector::looks_like_mock_server(&self.target) {
654 if let Ok(info) = MockServerDetector::detect(&self.target).await {
656 if info.is_mockforge {
657 TerminalReporter::print_success(&format!(
658 "Detected MockForge server (version: {})",
659 info.version.as_deref().unwrap_or("unknown")
660 ));
661 return MockIntegrationConfig::mock_server();
662 }
663 }
664 }
665 MockIntegrationConfig::real_api()
666 }
667
668 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
670 if !self.crud_flow {
671 return None;
672 }
673
674 if let Some(config_path) = &self.flow_config {
676 match CrudFlowConfig::from_file(config_path) {
677 Ok(config) => return Some(config),
678 Err(e) => {
679 TerminalReporter::print_warning(&format!(
680 "Failed to load flow config: {}. Using auto-detection.",
681 e
682 ));
683 }
684 }
685 }
686
687 let extract_fields = self
689 .extract_fields
690 .as_ref()
691 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
692 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
693
694 Some(CrudFlowConfig {
695 flows: Vec::new(), default_extract_fields: extract_fields,
697 })
698 }
699
700 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
702 let data_file = self.data_file.as_ref()?;
703
704 let distribution = DataDistribution::from_str(&self.data_distribution)
705 .unwrap_or(DataDistribution::UniquePerVu);
706
707 let mappings = self
708 .data_mappings
709 .as_ref()
710 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
711 .unwrap_or_default();
712
713 Some(DataDrivenConfig {
714 file_path: data_file.to_string_lossy().to_string(),
715 distribution,
716 mappings,
717 csv_has_header: true,
718 per_uri_control: self.per_uri_control,
719 per_uri_columns: crate::data_driven::PerUriColumns::default(),
720 })
721 }
722
723 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
725 let error_rate = self.error_rate?;
726
727 let error_types = self
728 .error_types
729 .as_ref()
730 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
731 .unwrap_or_default();
732
733 Some(InvalidDataConfig {
734 error_rate,
735 error_types,
736 target_fields: Vec::new(),
737 })
738 }
739
740 fn build_security_config(&self) -> Option<SecurityTestConfig> {
742 if !self.security_test {
743 return None;
744 }
745
746 let categories = self
747 .security_categories
748 .as_ref()
749 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
750 .unwrap_or_else(|| {
751 let mut default = std::collections::HashSet::new();
752 default.insert(SecurityCategory::SqlInjection);
753 default.insert(SecurityCategory::Xss);
754 default
755 });
756
757 let target_fields = self
758 .security_target_fields
759 .as_ref()
760 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
761 .unwrap_or_default();
762
763 let custom_payloads_file =
764 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
765
766 Some(SecurityTestConfig {
767 enabled: true,
768 categories,
769 target_fields,
770 custom_payloads_file,
771 include_high_risk: false,
772 })
773 }
774
775 fn build_parallel_config(&self) -> Option<ParallelConfig> {
777 let count = self.parallel_create?;
778
779 Some(ParallelConfig::new(count))
780 }
781
782 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
784 let Some(ref wafbench_dir) = self.wafbench_dir else {
785 return Vec::new();
786 };
787
788 let mut loader = WafBenchLoader::new();
789
790 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
791 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
792 return Vec::new();
793 }
794
795 let stats = loader.stats();
796
797 if stats.files_processed == 0 {
798 TerminalReporter::print_warning(&format!(
799 "No WAFBench YAML files found matching '{}'",
800 wafbench_dir
801 ));
802 if !stats.parse_errors.is_empty() {
804 TerminalReporter::print_warning("Some files were found but failed to parse:");
805 for error in &stats.parse_errors {
806 TerminalReporter::print_warning(&format!(" - {}", error));
807 }
808 }
809 return Vec::new();
810 }
811
812 TerminalReporter::print_progress(&format!(
813 "Loaded {} WAFBench files, {} test cases, {} payloads",
814 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
815 ));
816
817 for (category, count) in &stats.by_category {
819 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
820 }
821
822 for error in &stats.parse_errors {
824 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
825 }
826
827 loader.to_security_payloads()
828 }
829
830 pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
832 let mut enhanced_script = base_script.to_string();
833 let mut additional_code = String::new();
834
835 if let Some(config) = self.build_data_driven_config() {
837 TerminalReporter::print_progress("Adding data-driven testing support...");
838 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
839 additional_code.push('\n');
840 TerminalReporter::print_success("Data-driven testing enabled");
841 }
842
843 if let Some(config) = self.build_invalid_data_config() {
845 TerminalReporter::print_progress("Adding invalid data testing support...");
846 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
847 additional_code.push('\n');
848 additional_code
849 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
850 additional_code.push('\n');
851 additional_code
852 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
853 additional_code.push('\n');
854 TerminalReporter::print_success(&format!(
855 "Invalid data testing enabled ({}% error rate)",
856 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
857 ));
858 }
859
860 let security_config = self.build_security_config();
862 let wafbench_payloads = self.load_wafbench_payloads();
863 let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
864
865 if security_config.is_some() || !wafbench_payloads.is_empty() {
866 TerminalReporter::print_progress("Adding security testing support...");
867
868 let mut payload_list: Vec<SecurityPayload> = Vec::new();
870
871 if let Some(ref config) = security_config {
872 payload_list.extend(SecurityPayloads::get_payloads(config));
873 }
874
875 if !wafbench_payloads.is_empty() {
877 TerminalReporter::print_progress(&format!(
878 "Loading {} WAFBench attack patterns...",
879 wafbench_payloads.len()
880 ));
881 payload_list.extend(wafbench_payloads);
882 }
883
884 let target_fields =
885 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
886
887 additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
888 &payload_list,
889 self.wafbench_cycle_all,
890 ));
891 additional_code.push('\n');
892 additional_code
893 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
894 additional_code.push('\n');
895 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
896 additional_code.push('\n');
897
898 let mode = if self.wafbench_cycle_all {
899 "cycle-all"
900 } else {
901 "random"
902 };
903 TerminalReporter::print_success(&format!(
904 "Security testing enabled ({} payloads, {} mode)",
905 payload_list.len(),
906 mode
907 ));
908 } else if security_requested {
909 TerminalReporter::print_warning(
913 "Security testing was requested but no payloads were loaded. \
914 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
915 );
916 additional_code
917 .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
918 additional_code.push('\n');
919 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
920 additional_code.push('\n');
921 }
922
923 if let Some(config) = self.build_parallel_config() {
925 TerminalReporter::print_progress("Adding parallel execution support...");
926 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
927 additional_code.push('\n');
928 TerminalReporter::print_success(&format!(
929 "Parallel execution enabled (count: {})",
930 config.count
931 ));
932 }
933
934 if !additional_code.is_empty() {
936 if let Some(import_end) = enhanced_script.find("export const options") {
938 enhanced_script.insert_str(
939 import_end,
940 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
941 );
942 }
943 }
944
945 Ok(enhanced_script)
946 }
947
948 async fn execute_sequential_specs(&self) -> Result<()> {
950 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
951
952 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
954
955 if !self.spec.is_empty() {
956 let specs = load_specs_from_files(self.spec.clone())
957 .await
958 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
959 all_specs.extend(specs);
960 }
961
962 if let Some(spec_dir) = &self.spec_dir {
963 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
964 BenchError::Other(format!("Failed to load specs from directory: {}", e))
965 })?;
966 all_specs.extend(dir_specs);
967 }
968
969 if all_specs.is_empty() {
970 return Err(BenchError::Other(
971 "No spec files found for sequential execution".to_string(),
972 ));
973 }
974
975 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
976
977 let execution_order = if let Some(config_path) = &self.dependency_config {
979 TerminalReporter::print_progress("Loading dependency configuration...");
980 let config = SpecDependencyConfig::from_file(config_path)?;
981
982 if !config.disable_auto_detect && config.execution_order.is_empty() {
983 self.detect_and_sort_specs(&all_specs)?
985 } else {
986 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
988 }
989 } else {
990 self.detect_and_sort_specs(&all_specs)?
992 };
993
994 TerminalReporter::print_success(&format!(
995 "Execution order: {}",
996 execution_order
997 .iter()
998 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
999 .collect::<Vec<_>>()
1000 .join(" → ")
1001 ));
1002
1003 let mut extracted_values = ExtractedValues::new();
1005 let total_specs = execution_order.len();
1006
1007 for (index, spec_path) in execution_order.iter().enumerate() {
1008 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1009
1010 TerminalReporter::print_progress(&format!(
1011 "[{}/{}] Executing spec: {}",
1012 index + 1,
1013 total_specs,
1014 spec_name
1015 ));
1016
1017 let spec = all_specs
1019 .iter()
1020 .find(|(p, _)| {
1021 p == spec_path
1022 || p.file_name() == spec_path.file_name()
1023 || p.file_name() == Some(spec_path.as_os_str())
1024 })
1025 .map(|(_, s)| s.clone())
1026 .ok_or_else(|| {
1027 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1028 })?;
1029
1030 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1032
1033 extracted_values.merge(&new_values);
1035
1036 TerminalReporter::print_success(&format!(
1037 "[{}/{}] Completed: {} (extracted {} values)",
1038 index + 1,
1039 total_specs,
1040 spec_name,
1041 new_values.values.len()
1042 ));
1043 }
1044
1045 TerminalReporter::print_success(&format!(
1046 "Sequential execution complete: {} specs executed",
1047 total_specs
1048 ));
1049
1050 Ok(())
1051 }
1052
1053 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1055 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1056
1057 let mut detector = DependencyDetector::new();
1058 let dependencies = detector.detect_dependencies(specs);
1059
1060 if dependencies.is_empty() {
1061 TerminalReporter::print_progress("No dependencies detected, using file order");
1062 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1063 }
1064
1065 TerminalReporter::print_progress(&format!(
1066 "Detected {} cross-spec dependencies",
1067 dependencies.len()
1068 ));
1069
1070 for dep in &dependencies {
1071 TerminalReporter::print_progress(&format!(
1072 " {} → {} (via field '{}')",
1073 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1074 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1075 dep.field_name
1076 ));
1077 }
1078
1079 topological_sort(specs, &dependencies)
1080 }
1081
1082 async fn execute_single_spec(
1084 &self,
1085 spec: &OpenApiSpec,
1086 spec_name: &str,
1087 _external_values: &ExtractedValues,
1088 ) -> Result<ExtractedValues> {
1089 let parser = SpecParser::from_spec(spec.clone());
1090
1091 if self.crud_flow {
1093 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1095 } else {
1096 self.execute_standard_spec(&parser, spec_name).await?;
1098 Ok(ExtractedValues::new())
1099 }
1100 }
1101
1102 async fn execute_crud_flow_with_extraction(
1104 &self,
1105 parser: &SpecParser,
1106 spec_name: &str,
1107 ) -> Result<ExtractedValues> {
1108 let operations = parser.get_operations();
1109 let flows = CrudFlowDetector::detect_flows(&operations);
1110
1111 if flows.is_empty() {
1112 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1113 return Ok(ExtractedValues::new());
1114 }
1115
1116 TerminalReporter::print_progress(&format!(
1117 " {} CRUD flow(s) in {}",
1118 flows.len(),
1119 spec_name
1120 ));
1121
1122 let mut handlebars = handlebars::Handlebars::new();
1124 handlebars.register_helper(
1126 "json",
1127 Box::new(
1128 |h: &handlebars::Helper,
1129 _: &handlebars::Handlebars,
1130 _: &handlebars::Context,
1131 _: &mut handlebars::RenderContext,
1132 out: &mut dyn handlebars::Output|
1133 -> handlebars::HelperResult {
1134 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1135 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1136 Ok(())
1137 },
1138 ),
1139 );
1140 let template = include_str!("templates/k6_crud_flow.hbs");
1141
1142 let custom_headers = self.parse_headers()?;
1143 let config = self.build_crud_flow_config().unwrap_or_default();
1144
1145 let param_overrides = if let Some(params_file) = &self.params_file {
1147 let overrides = ParameterOverrides::from_file(params_file)?;
1148 Some(overrides)
1149 } else {
1150 None
1151 };
1152
1153 let duration_secs = Self::parse_duration(&self.duration)?;
1155 let scenario =
1156 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1157 let stages = scenario.generate_stages(duration_secs, self.vus);
1158
1159 let api_base_path = self.resolve_base_path(parser);
1161
1162 let mut all_headers = custom_headers.clone();
1164 if let Some(auth) = &self.auth {
1165 all_headers.insert("Authorization".to_string(), auth.clone());
1166 }
1167 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1168
1169 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1171
1172 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1173 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1174 serde_json::json!({
1175 "name": sanitized_name.clone(),
1176 "display_name": f.name,
1177 "base_path": f.base_path,
1178 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1179 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1181 let method_raw = if !parts.is_empty() {
1182 parts[0].to_uppercase()
1183 } else {
1184 "GET".to_string()
1185 };
1186 let method = if !parts.is_empty() {
1187 let m = parts[0].to_lowercase();
1188 if m == "delete" { "del".to_string() } else { m }
1190 } else {
1191 "get".to_string()
1192 };
1193 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1194 let path = if let Some(ref bp) = api_base_path {
1196 format!("{}{}", bp, raw_path)
1197 } else {
1198 raw_path.to_string()
1199 };
1200 let is_get_or_head = method == "get" || method == "head";
1201 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1203
1204 let body_value = if has_body {
1206 param_overrides.as_ref()
1207 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1208 .and_then(|oo| oo.body)
1209 .unwrap_or_else(|| serde_json::json!({}))
1210 } else {
1211 serde_json::json!({})
1212 };
1213
1214 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1216
1217 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1219 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1220
1221 serde_json::json!({
1222 "operation": s.operation,
1223 "method": method,
1224 "path": path,
1225 "extract": s.extract,
1226 "use_values": s.use_values,
1227 "use_body": s.use_body,
1228 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1229 "inject_attacks": s.inject_attacks,
1230 "attack_types": s.attack_types,
1231 "description": s.description,
1232 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1233 "is_get_or_head": is_get_or_head,
1234 "has_body": has_body,
1235 "body": processed_body.value,
1236 "body_is_dynamic": body_is_dynamic,
1237 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1238 })
1239 }).collect::<Vec<_>>(),
1240 })
1241 }).collect();
1242
1243 for flow_data in &flows_data {
1245 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1246 for step in steps {
1247 if let Some(placeholders_arr) =
1248 step.get("_placeholders").and_then(|p| p.as_array())
1249 {
1250 for p_str in placeholders_arr {
1251 if let Some(p_name) = p_str.as_str() {
1252 match p_name {
1253 "VU" => {
1254 all_placeholders.insert(DynamicPlaceholder::VU);
1255 }
1256 "Iteration" => {
1257 all_placeholders.insert(DynamicPlaceholder::Iteration);
1258 }
1259 "Timestamp" => {
1260 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1261 }
1262 "UUID" => {
1263 all_placeholders.insert(DynamicPlaceholder::UUID);
1264 }
1265 "Random" => {
1266 all_placeholders.insert(DynamicPlaceholder::Random);
1267 }
1268 "Counter" => {
1269 all_placeholders.insert(DynamicPlaceholder::Counter);
1270 }
1271 "Date" => {
1272 all_placeholders.insert(DynamicPlaceholder::Date);
1273 }
1274 "VuIter" => {
1275 all_placeholders.insert(DynamicPlaceholder::VuIter);
1276 }
1277 _ => {}
1278 }
1279 }
1280 }
1281 }
1282 }
1283 }
1284 }
1285
1286 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1288 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1289
1290 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1292
1293 let data = serde_json::json!({
1294 "base_url": self.target,
1295 "flows": flows_data,
1296 "extract_fields": config.default_extract_fields,
1297 "duration_secs": duration_secs,
1298 "max_vus": self.vus,
1299 "auth_header": self.auth,
1300 "custom_headers": custom_headers,
1301 "skip_tls_verify": self.skip_tls_verify,
1302 "stages": stages.iter().map(|s| serde_json::json!({
1304 "duration": s.duration,
1305 "target": s.target,
1306 })).collect::<Vec<_>>(),
1307 "threshold_percentile": self.threshold_percentile,
1308 "threshold_ms": self.threshold_ms,
1309 "max_error_rate": self.max_error_rate,
1310 "headers": headers_json,
1311 "dynamic_imports": required_imports,
1312 "dynamic_globals": required_globals,
1313 "security_testing_enabled": security_testing_enabled,
1315 "has_custom_headers": !custom_headers.is_empty(),
1316 });
1317
1318 let mut script = handlebars
1319 .render_template(template, &data)
1320 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1321
1322 if security_testing_enabled {
1324 script = self.generate_enhanced_script(&script)?;
1325 }
1326
1327 let script_path =
1329 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1330
1331 std::fs::create_dir_all(self.output.clone())?;
1332 std::fs::write(&script_path, &script)?;
1333
1334 if !self.generate_only {
1335 let executor = K6Executor::new()?;
1336 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1337 std::fs::create_dir_all(&output_dir)?;
1338
1339 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1340 }
1341
1342 Ok(ExtractedValues::new())
1345 }
1346
1347 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1349 let mut operations = if let Some(filter) = &self.operations {
1350 parser.filter_operations(filter)?
1351 } else {
1352 parser.get_operations()
1353 };
1354
1355 if let Some(exclude) = &self.exclude_operations {
1356 operations = parser.exclude_operations(operations, exclude)?;
1357 }
1358
1359 if operations.is_empty() {
1360 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1361 return Ok(());
1362 }
1363
1364 TerminalReporter::print_progress(&format!(
1365 " {} operations in {}",
1366 operations.len(),
1367 spec_name
1368 ));
1369
1370 let templates: Vec<_> = operations
1372 .iter()
1373 .map(RequestGenerator::generate_template)
1374 .collect::<Result<Vec<_>>>()?;
1375
1376 let custom_headers = self.parse_headers()?;
1378
1379 let base_path = self.resolve_base_path(parser);
1381
1382 let scenario =
1384 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1385
1386 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
1387
1388 let k6_config = K6Config {
1389 target_url: self.target.clone(),
1390 base_path,
1391 scenario,
1392 duration_secs: Self::parse_duration(&self.duration)?,
1393 max_vus: self.vus,
1394 threshold_percentile: self.threshold_percentile.clone(),
1395 threshold_ms: self.threshold_ms,
1396 max_error_rate: self.max_error_rate,
1397 auth_header: self.auth.clone(),
1398 custom_headers,
1399 skip_tls_verify: self.skip_tls_verify,
1400 security_testing_enabled,
1401 };
1402
1403 let generator = K6ScriptGenerator::new(k6_config, templates);
1404 let mut script = generator.generate()?;
1405
1406 let has_advanced_features = self.data_file.is_some()
1408 || self.error_rate.is_some()
1409 || self.security_test
1410 || self.parallel_create.is_some()
1411 || self.wafbench_dir.is_some();
1412
1413 if has_advanced_features {
1414 script = self.generate_enhanced_script(&script)?;
1415 }
1416
1417 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1419
1420 std::fs::create_dir_all(self.output.clone())?;
1421 std::fs::write(&script_path, &script)?;
1422
1423 if !self.generate_only {
1424 let executor = K6Executor::new()?;
1425 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1426 std::fs::create_dir_all(&output_dir)?;
1427
1428 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1429 }
1430
1431 Ok(())
1432 }
1433
1434 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1436 let config = self.build_crud_flow_config().unwrap_or_default();
1438
1439 let flows = if !config.flows.is_empty() {
1441 TerminalReporter::print_progress("Using custom flow configuration...");
1442 config.flows.clone()
1443 } else {
1444 TerminalReporter::print_progress("Detecting CRUD operations...");
1445 let operations = parser.get_operations();
1446 CrudFlowDetector::detect_flows(&operations)
1447 };
1448
1449 if flows.is_empty() {
1450 return Err(BenchError::Other(
1451 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1452 ));
1453 }
1454
1455 if config.flows.is_empty() {
1456 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1457 } else {
1458 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1459 }
1460
1461 for flow in &flows {
1462 TerminalReporter::print_progress(&format!(
1463 " - {}: {} steps",
1464 flow.name,
1465 flow.steps.len()
1466 ));
1467 }
1468
1469 let mut handlebars = handlebars::Handlebars::new();
1471 handlebars.register_helper(
1473 "json",
1474 Box::new(
1475 |h: &handlebars::Helper,
1476 _: &handlebars::Handlebars,
1477 _: &handlebars::Context,
1478 _: &mut handlebars::RenderContext,
1479 out: &mut dyn handlebars::Output|
1480 -> handlebars::HelperResult {
1481 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1482 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1483 Ok(())
1484 },
1485 ),
1486 );
1487 let template = include_str!("templates/k6_crud_flow.hbs");
1488
1489 let custom_headers = self.parse_headers()?;
1490
1491 let param_overrides = if let Some(params_file) = &self.params_file {
1493 TerminalReporter::print_progress("Loading parameter overrides...");
1494 let overrides = ParameterOverrides::from_file(params_file)?;
1495 TerminalReporter::print_success(&format!(
1496 "Loaded parameter overrides ({} operation-specific, {} defaults)",
1497 overrides.operations.len(),
1498 if overrides.defaults.is_empty() { 0 } else { 1 }
1499 ));
1500 Some(overrides)
1501 } else {
1502 None
1503 };
1504
1505 let duration_secs = Self::parse_duration(&self.duration)?;
1507 let scenario =
1508 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1509 let stages = scenario.generate_stages(duration_secs, self.vus);
1510
1511 let api_base_path = self.resolve_base_path(parser);
1513 if let Some(ref bp) = api_base_path {
1514 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1515 }
1516
1517 let mut all_headers = custom_headers.clone();
1519 if let Some(auth) = &self.auth {
1520 all_headers.insert("Authorization".to_string(), auth.clone());
1521 }
1522 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1523
1524 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1526
1527 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1528 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1530 serde_json::json!({
1531 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1534 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1535 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1537 let method_raw = if !parts.is_empty() {
1538 parts[0].to_uppercase()
1539 } else {
1540 "GET".to_string()
1541 };
1542 let method = if !parts.is_empty() {
1543 let m = parts[0].to_lowercase();
1544 if m == "delete" { "del".to_string() } else { m }
1546 } else {
1547 "get".to_string()
1548 };
1549 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1550 let path = if let Some(ref bp) = api_base_path {
1552 format!("{}{}", bp, raw_path)
1553 } else {
1554 raw_path.to_string()
1555 };
1556 let is_get_or_head = method == "get" || method == "head";
1557 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1559
1560 let body_value = if has_body {
1562 param_overrides.as_ref()
1563 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1564 .and_then(|oo| oo.body)
1565 .unwrap_or_else(|| serde_json::json!({}))
1566 } else {
1567 serde_json::json!({})
1568 };
1569
1570 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1572 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1577 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1578
1579 serde_json::json!({
1580 "operation": s.operation,
1581 "method": method,
1582 "path": path,
1583 "extract": s.extract,
1584 "use_values": s.use_values,
1585 "use_body": s.use_body,
1586 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1587 "inject_attacks": s.inject_attacks,
1588 "attack_types": s.attack_types,
1589 "description": s.description,
1590 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1591 "is_get_or_head": is_get_or_head,
1592 "has_body": has_body,
1593 "body": processed_body.value,
1594 "body_is_dynamic": body_is_dynamic,
1595 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1596 })
1597 }).collect::<Vec<_>>(),
1598 })
1599 }).collect();
1600
1601 for flow_data in &flows_data {
1603 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1604 for step in steps {
1605 if let Some(placeholders_arr) =
1606 step.get("_placeholders").and_then(|p| p.as_array())
1607 {
1608 for p_str in placeholders_arr {
1609 if let Some(p_name) = p_str.as_str() {
1610 match p_name {
1612 "VU" => {
1613 all_placeholders.insert(DynamicPlaceholder::VU);
1614 }
1615 "Iteration" => {
1616 all_placeholders.insert(DynamicPlaceholder::Iteration);
1617 }
1618 "Timestamp" => {
1619 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1620 }
1621 "UUID" => {
1622 all_placeholders.insert(DynamicPlaceholder::UUID);
1623 }
1624 "Random" => {
1625 all_placeholders.insert(DynamicPlaceholder::Random);
1626 }
1627 "Counter" => {
1628 all_placeholders.insert(DynamicPlaceholder::Counter);
1629 }
1630 "Date" => {
1631 all_placeholders.insert(DynamicPlaceholder::Date);
1632 }
1633 "VuIter" => {
1634 all_placeholders.insert(DynamicPlaceholder::VuIter);
1635 }
1636 _ => {}
1637 }
1638 }
1639 }
1640 }
1641 }
1642 }
1643 }
1644
1645 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1647 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1648
1649 let invalid_data_config = self.build_invalid_data_config();
1651 let error_injection_enabled = invalid_data_config.is_some();
1652 let error_rate = self.error_rate.unwrap_or(0.0);
1653 let error_types: Vec<String> = invalid_data_config
1654 .as_ref()
1655 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1656 .unwrap_or_default();
1657
1658 if error_injection_enabled {
1659 TerminalReporter::print_progress(&format!(
1660 "Error injection enabled ({}% rate)",
1661 (error_rate * 100.0) as u32
1662 ));
1663 }
1664
1665 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1667
1668 let data = serde_json::json!({
1669 "base_url": self.target,
1670 "flows": flows_data,
1671 "extract_fields": config.default_extract_fields,
1672 "duration_secs": duration_secs,
1673 "max_vus": self.vus,
1674 "auth_header": self.auth,
1675 "custom_headers": custom_headers,
1676 "skip_tls_verify": self.skip_tls_verify,
1677 "stages": stages.iter().map(|s| serde_json::json!({
1679 "duration": s.duration,
1680 "target": s.target,
1681 })).collect::<Vec<_>>(),
1682 "threshold_percentile": self.threshold_percentile,
1683 "threshold_ms": self.threshold_ms,
1684 "max_error_rate": self.max_error_rate,
1685 "headers": headers_json,
1686 "dynamic_imports": required_imports,
1687 "dynamic_globals": required_globals,
1688 "error_injection_enabled": error_injection_enabled,
1690 "error_rate": error_rate,
1691 "error_types": error_types,
1692 "security_testing_enabled": security_testing_enabled,
1694 "has_custom_headers": !custom_headers.is_empty(),
1695 });
1696
1697 let mut script = handlebars
1698 .render_template(template, &data)
1699 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1700
1701 if security_testing_enabled {
1703 script = self.generate_enhanced_script(&script)?;
1704 }
1705
1706 TerminalReporter::print_progress("Validating CRUD flow script...");
1708 let validation_errors = K6ScriptGenerator::validate_script(&script);
1709 if !validation_errors.is_empty() {
1710 TerminalReporter::print_error("CRUD flow script validation failed");
1711 for error in &validation_errors {
1712 eprintln!(" {}", error);
1713 }
1714 return Err(BenchError::Other(format!(
1715 "CRUD flow script validation failed with {} error(s)",
1716 validation_errors.len()
1717 )));
1718 }
1719
1720 TerminalReporter::print_success("CRUD flow script generated");
1721
1722 let script_path = if let Some(output) = &self.script_output {
1724 output.clone()
1725 } else {
1726 self.output.join("k6-crud-flow-script.js")
1727 };
1728
1729 if let Some(parent) = script_path.parent() {
1730 std::fs::create_dir_all(parent)?;
1731 }
1732 std::fs::write(&script_path, &script)?;
1733 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1734
1735 if self.generate_only {
1736 println!("\nScript generated successfully. Run it with:");
1737 println!(" k6 run {}", script_path.display());
1738 return Ok(());
1739 }
1740
1741 TerminalReporter::print_progress("Executing CRUD flow test...");
1743 let executor = K6Executor::new()?;
1744 std::fs::create_dir_all(&self.output)?;
1745
1746 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1747
1748 let duration_secs = Self::parse_duration(&self.duration)?;
1749 TerminalReporter::print_summary(&results, duration_secs);
1750
1751 Ok(())
1752 }
1753
1754 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
1756 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
1757
1758 let custom_headers = self.parse_headers()?;
1760
1761 let mut config = OwaspApiConfig::new()
1763 .with_auth_header(&self.owasp_auth_header)
1764 .with_verbose(self.verbose)
1765 .with_insecure(self.skip_tls_verify)
1766 .with_concurrency(self.vus as usize)
1767 .with_iterations(self.owasp_iterations as usize)
1768 .with_base_path(self.base_path.clone())
1769 .with_custom_headers(custom_headers);
1770
1771 if let Some(ref token) = self.owasp_auth_token {
1773 config = config.with_valid_auth_token(token);
1774 }
1775
1776 if let Some(ref cats_str) = self.owasp_categories {
1778 let categories: Vec<OwaspCategory> = cats_str
1779 .split(',')
1780 .filter_map(|s| {
1781 let trimmed = s.trim();
1782 match trimmed.parse::<OwaspCategory>() {
1783 Ok(cat) => Some(cat),
1784 Err(e) => {
1785 TerminalReporter::print_warning(&e);
1786 None
1787 }
1788 }
1789 })
1790 .collect();
1791
1792 if !categories.is_empty() {
1793 config = config.with_categories(categories);
1794 }
1795 }
1796
1797 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
1799 config.admin_paths_file = Some(admin_paths_file.clone());
1800 if let Err(e) = config.load_admin_paths() {
1801 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
1802 }
1803 }
1804
1805 if let Some(ref id_fields_str) = self.owasp_id_fields {
1807 let id_fields: Vec<String> = id_fields_str
1808 .split(',')
1809 .map(|s| s.trim().to_string())
1810 .filter(|s| !s.is_empty())
1811 .collect();
1812 if !id_fields.is_empty() {
1813 config = config.with_id_fields(id_fields);
1814 }
1815 }
1816
1817 if let Some(ref report_path) = self.owasp_report {
1819 config = config.with_report_path(report_path);
1820 }
1821 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
1822 config = config.with_report_format(format);
1823 }
1824
1825 let categories = config.categories_to_test();
1827 TerminalReporter::print_success(&format!(
1828 "Testing {} OWASP categories: {}",
1829 categories.len(),
1830 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
1831 ));
1832
1833 if config.valid_auth_token.is_some() {
1834 TerminalReporter::print_progress("Using provided auth token for baseline requests");
1835 }
1836
1837 TerminalReporter::print_progress("Generating OWASP security test script...");
1839 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
1840
1841 let script = generator.generate()?;
1843 TerminalReporter::print_success("OWASP security test script generated");
1844
1845 let script_path = if let Some(output) = &self.script_output {
1847 output.clone()
1848 } else {
1849 self.output.join("k6-owasp-security-test.js")
1850 };
1851
1852 if let Some(parent) = script_path.parent() {
1853 std::fs::create_dir_all(parent)?;
1854 }
1855 std::fs::write(&script_path, &script)?;
1856 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1857
1858 if self.generate_only {
1860 println!("\nOWASP security test script generated. Run it with:");
1861 println!(" k6 run {}", script_path.display());
1862 return Ok(());
1863 }
1864
1865 TerminalReporter::print_progress("Executing OWASP security tests...");
1867 let executor = K6Executor::new()?;
1868 std::fs::create_dir_all(&self.output)?;
1869
1870 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1871
1872 let duration_secs = Self::parse_duration(&self.duration)?;
1873 TerminalReporter::print_summary(&results, duration_secs);
1874
1875 println!("\nOWASP security test results saved to: {}", self.output.display());
1876
1877 Ok(())
1878 }
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883 use super::*;
1884
1885 #[test]
1886 fn test_parse_duration() {
1887 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
1888 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
1889 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
1890 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
1891 }
1892
1893 #[test]
1894 fn test_parse_duration_invalid() {
1895 assert!(BenchCommand::parse_duration("invalid").is_err());
1896 assert!(BenchCommand::parse_duration("30x").is_err());
1897 }
1898
1899 #[test]
1900 fn test_parse_headers() {
1901 let cmd = BenchCommand {
1902 spec: vec![PathBuf::from("test.yaml")],
1903 spec_dir: None,
1904 merge_conflicts: "error".to_string(),
1905 spec_mode: "merge".to_string(),
1906 dependency_config: None,
1907 target: "http://localhost".to_string(),
1908 base_path: None,
1909 duration: "1m".to_string(),
1910 vus: 10,
1911 scenario: "ramp-up".to_string(),
1912 operations: None,
1913 exclude_operations: None,
1914 auth: None,
1915 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
1916 output: PathBuf::from("output"),
1917 generate_only: false,
1918 script_output: None,
1919 threshold_percentile: "p(95)".to_string(),
1920 threshold_ms: 500,
1921 max_error_rate: 0.05,
1922 verbose: false,
1923 skip_tls_verify: false,
1924 targets_file: None,
1925 max_concurrency: None,
1926 results_format: "both".to_string(),
1927 params_file: None,
1928 crud_flow: false,
1929 flow_config: None,
1930 extract_fields: None,
1931 parallel_create: None,
1932 data_file: None,
1933 data_distribution: "unique-per-vu".to_string(),
1934 data_mappings: None,
1935 per_uri_control: false,
1936 error_rate: None,
1937 error_types: None,
1938 security_test: false,
1939 security_payloads: None,
1940 security_categories: None,
1941 security_target_fields: None,
1942 wafbench_dir: None,
1943 wafbench_cycle_all: false,
1944 owasp_api_top10: false,
1945 owasp_categories: None,
1946 owasp_auth_header: "Authorization".to_string(),
1947 owasp_auth_token: None,
1948 owasp_admin_paths: None,
1949 owasp_id_fields: None,
1950 owasp_report: None,
1951 owasp_report_format: "json".to_string(),
1952 owasp_iterations: 1,
1953 };
1954
1955 let headers = cmd.parse_headers().unwrap();
1956 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
1957 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
1958 }
1959
1960 #[test]
1961 fn test_get_spec_display_name() {
1962 let cmd = BenchCommand {
1963 spec: vec![PathBuf::from("test.yaml")],
1964 spec_dir: None,
1965 merge_conflicts: "error".to_string(),
1966 spec_mode: "merge".to_string(),
1967 dependency_config: None,
1968 target: "http://localhost".to_string(),
1969 base_path: None,
1970 duration: "1m".to_string(),
1971 vus: 10,
1972 scenario: "ramp-up".to_string(),
1973 operations: None,
1974 exclude_operations: None,
1975 auth: None,
1976 headers: None,
1977 output: PathBuf::from("output"),
1978 generate_only: false,
1979 script_output: None,
1980 threshold_percentile: "p(95)".to_string(),
1981 threshold_ms: 500,
1982 max_error_rate: 0.05,
1983 verbose: false,
1984 skip_tls_verify: false,
1985 targets_file: None,
1986 max_concurrency: None,
1987 results_format: "both".to_string(),
1988 params_file: None,
1989 crud_flow: false,
1990 flow_config: None,
1991 extract_fields: None,
1992 parallel_create: None,
1993 data_file: None,
1994 data_distribution: "unique-per-vu".to_string(),
1995 data_mappings: None,
1996 per_uri_control: false,
1997 error_rate: None,
1998 error_types: None,
1999 security_test: false,
2000 security_payloads: None,
2001 security_categories: None,
2002 security_target_fields: None,
2003 wafbench_dir: None,
2004 wafbench_cycle_all: false,
2005 owasp_api_top10: false,
2006 owasp_categories: None,
2007 owasp_auth_header: "Authorization".to_string(),
2008 owasp_auth_token: None,
2009 owasp_admin_paths: None,
2010 owasp_id_fields: None,
2011 owasp_report: None,
2012 owasp_report_format: "json".to_string(),
2013 owasp_iterations: 1,
2014 };
2015
2016 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
2017
2018 let cmd_multi = BenchCommand {
2020 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
2021 spec_dir: None,
2022 merge_conflicts: "error".to_string(),
2023 spec_mode: "merge".to_string(),
2024 dependency_config: None,
2025 target: "http://localhost".to_string(),
2026 base_path: None,
2027 duration: "1m".to_string(),
2028 vus: 10,
2029 scenario: "ramp-up".to_string(),
2030 operations: None,
2031 exclude_operations: None,
2032 auth: None,
2033 headers: None,
2034 output: PathBuf::from("output"),
2035 generate_only: false,
2036 script_output: None,
2037 threshold_percentile: "p(95)".to_string(),
2038 threshold_ms: 500,
2039 max_error_rate: 0.05,
2040 verbose: false,
2041 skip_tls_verify: false,
2042 targets_file: None,
2043 max_concurrency: None,
2044 results_format: "both".to_string(),
2045 params_file: None,
2046 crud_flow: false,
2047 flow_config: None,
2048 extract_fields: None,
2049 parallel_create: None,
2050 data_file: None,
2051 data_distribution: "unique-per-vu".to_string(),
2052 data_mappings: None,
2053 per_uri_control: false,
2054 error_rate: None,
2055 error_types: None,
2056 security_test: false,
2057 security_payloads: None,
2058 security_categories: None,
2059 security_target_fields: None,
2060 wafbench_dir: None,
2061 wafbench_cycle_all: false,
2062 owasp_api_top10: false,
2063 owasp_categories: None,
2064 owasp_auth_header: "Authorization".to_string(),
2065 owasp_auth_token: None,
2066 owasp_admin_paths: None,
2067 owasp_id_fields: None,
2068 owasp_report: None,
2069 owasp_report_format: "json".to_string(),
2070 owasp_iterations: 1,
2071 };
2072
2073 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2074 }
2075}