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, InvalidDataType};
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 k6_config = K6Config {
334 target_url: self.target.clone(),
335 base_path,
336 scenario,
337 duration_secs: Self::parse_duration(&self.duration)?,
338 max_vus: self.vus,
339 threshold_percentile: self.threshold_percentile.clone(),
340 threshold_ms: self.threshold_ms,
341 max_error_rate: self.max_error_rate,
342 auth_header: self.auth.clone(),
343 custom_headers,
344 skip_tls_verify: self.skip_tls_verify,
345 };
346
347 let generator = K6ScriptGenerator::new(k6_config, templates);
348 let mut script = generator.generate()?;
349 TerminalReporter::print_success("k6 script generated");
350
351 let has_advanced_features = self.data_file.is_some()
353 || self.error_rate.is_some()
354 || self.security_test
355 || self.parallel_create.is_some();
356
357 if has_advanced_features {
359 script = self.generate_enhanced_script(&script)?;
360 }
361
362 if mock_config.is_mock_server {
364 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
365 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
366 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
367
368 if let Some(import_end) = script.find("export const options") {
370 script.insert_str(
371 import_end,
372 &format!(
373 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
374 helper_code, setup_code, teardown_code
375 ),
376 );
377 }
378 }
379
380 TerminalReporter::print_progress("Validating k6 script...");
382 let validation_errors = K6ScriptGenerator::validate_script(&script);
383 if !validation_errors.is_empty() {
384 TerminalReporter::print_error("Script validation failed");
385 for error in &validation_errors {
386 eprintln!(" {}", error);
387 }
388 return Err(BenchError::Other(format!(
389 "Generated k6 script has {} validation error(s). Please check the output above.",
390 validation_errors.len()
391 )));
392 }
393 TerminalReporter::print_success("Script validation passed");
394
395 let script_path = if let Some(output) = &self.script_output {
397 output.clone()
398 } else {
399 self.output.join("k6-script.js")
400 };
401
402 if let Some(parent) = script_path.parent() {
403 std::fs::create_dir_all(parent)?;
404 }
405 std::fs::write(&script_path, &script)?;
406 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
407
408 if self.generate_only {
410 println!("\nScript generated successfully. Run it with:");
411 println!(" k6 run {}", script_path.display());
412 return Ok(());
413 }
414
415 TerminalReporter::print_progress("Executing load test...");
417 let executor = K6Executor::new()?;
418
419 std::fs::create_dir_all(&self.output)?;
420
421 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
422
423 let duration_secs = Self::parse_duration(&self.duration)?;
425 TerminalReporter::print_summary(&results, duration_secs);
426
427 println!("\nResults saved to: {}", self.output.display());
428
429 Ok(())
430 }
431
432 async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
434 TerminalReporter::print_progress("Parsing targets file...");
435 let targets = parse_targets_file(targets_file)?;
436 let num_targets = targets.len();
437 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
438
439 if targets.is_empty() {
440 return Err(BenchError::Other("No targets found in file".to_string()));
441 }
442
443 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
445 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
449 &self.get_spec_display_name(),
450 &format!("{} targets", num_targets),
451 0,
452 &self.scenario,
453 Self::parse_duration(&self.duration)?,
454 );
455
456 let executor = ParallelExecutor::new(
458 BenchCommand {
459 spec: self.spec.clone(),
461 spec_dir: self.spec_dir.clone(),
462 merge_conflicts: self.merge_conflicts.clone(),
463 spec_mode: self.spec_mode.clone(),
464 dependency_config: self.dependency_config.clone(),
465 target: self.target.clone(), base_path: self.base_path.clone(),
467 duration: self.duration.clone(),
468 vus: self.vus,
469 scenario: self.scenario.clone(),
470 operations: self.operations.clone(),
471 exclude_operations: self.exclude_operations.clone(),
472 auth: self.auth.clone(),
473 headers: self.headers.clone(),
474 output: self.output.clone(),
475 generate_only: self.generate_only,
476 script_output: self.script_output.clone(),
477 threshold_percentile: self.threshold_percentile.clone(),
478 threshold_ms: self.threshold_ms,
479 max_error_rate: self.max_error_rate,
480 verbose: self.verbose,
481 skip_tls_verify: self.skip_tls_verify,
482 targets_file: None,
483 max_concurrency: None,
484 results_format: self.results_format.clone(),
485 params_file: self.params_file.clone(),
486 crud_flow: self.crud_flow,
487 flow_config: self.flow_config.clone(),
488 extract_fields: self.extract_fields.clone(),
489 parallel_create: self.parallel_create,
490 data_file: self.data_file.clone(),
491 data_distribution: self.data_distribution.clone(),
492 data_mappings: self.data_mappings.clone(),
493 per_uri_control: self.per_uri_control,
494 error_rate: self.error_rate,
495 error_types: self.error_types.clone(),
496 security_test: self.security_test,
497 security_payloads: self.security_payloads.clone(),
498 security_categories: self.security_categories.clone(),
499 security_target_fields: self.security_target_fields.clone(),
500 wafbench_dir: self.wafbench_dir.clone(),
501 wafbench_cycle_all: self.wafbench_cycle_all,
502 owasp_api_top10: self.owasp_api_top10,
503 owasp_categories: self.owasp_categories.clone(),
504 owasp_auth_header: self.owasp_auth_header.clone(),
505 owasp_auth_token: self.owasp_auth_token.clone(),
506 owasp_admin_paths: self.owasp_admin_paths.clone(),
507 owasp_id_fields: self.owasp_id_fields.clone(),
508 owasp_report: self.owasp_report.clone(),
509 owasp_report_format: self.owasp_report_format.clone(),
510 owasp_iterations: self.owasp_iterations,
511 },
512 targets,
513 max_concurrency,
514 );
515
516 let aggregated_results = executor.execute_all().await?;
518
519 self.report_multi_target_results(&aggregated_results)?;
521
522 Ok(())
523 }
524
525 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
527 TerminalReporter::print_multi_target_summary(results);
529
530 if self.results_format == "aggregated" || self.results_format == "both" {
532 let summary_path = self.output.join("aggregated_summary.json");
533 let summary_json = serde_json::json!({
534 "total_targets": results.total_targets,
535 "successful_targets": results.successful_targets,
536 "failed_targets": results.failed_targets,
537 "aggregated_metrics": {
538 "total_requests": results.aggregated_metrics.total_requests,
539 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
540 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
541 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
542 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
543 "error_rate": results.aggregated_metrics.error_rate,
544 },
545 "target_results": results.target_results.iter().map(|r| {
546 serde_json::json!({
547 "target_url": r.target_url,
548 "target_index": r.target_index,
549 "success": r.success,
550 "error": r.error,
551 "total_requests": r.results.total_requests,
552 "failed_requests": r.results.failed_requests,
553 "avg_duration_ms": r.results.avg_duration_ms,
554 "p95_duration_ms": r.results.p95_duration_ms,
555 "p99_duration_ms": r.results.p99_duration_ms,
556 "output_dir": r.output_dir.to_string_lossy(),
557 })
558 }).collect::<Vec<_>>(),
559 });
560
561 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
562 TerminalReporter::print_success(&format!(
563 "Aggregated summary saved to: {}",
564 summary_path.display()
565 ));
566 }
567
568 println!("\nResults saved to: {}", self.output.display());
569 println!(" - Per-target results: {}", self.output.join("target_*").display());
570 if self.results_format == "aggregated" || self.results_format == "both" {
571 println!(
572 " - Aggregated summary: {}",
573 self.output.join("aggregated_summary.json").display()
574 );
575 }
576
577 Ok(())
578 }
579
580 pub fn parse_duration(duration: &str) -> Result<u64> {
582 let duration = duration.trim();
583
584 if let Some(secs) = duration.strip_suffix('s') {
585 secs.parse::<u64>()
586 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
587 } else if let Some(mins) = duration.strip_suffix('m') {
588 mins.parse::<u64>()
589 .map(|m| m * 60)
590 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
591 } else if let Some(hours) = duration.strip_suffix('h') {
592 hours
593 .parse::<u64>()
594 .map(|h| h * 3600)
595 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
596 } else {
597 duration
599 .parse::<u64>()
600 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
601 }
602 }
603
604 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
606 let mut headers = HashMap::new();
607
608 if let Some(header_str) = &self.headers {
609 for pair in header_str.split(',') {
610 let parts: Vec<&str> = pair.splitn(2, ':').collect();
611 if parts.len() != 2 {
612 return Err(BenchError::Other(format!(
613 "Invalid header format: '{}'. Expected 'Key:Value'",
614 pair
615 )));
616 }
617 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
618 }
619 }
620
621 Ok(headers)
622 }
623
624 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
633 if let Some(cli_base_path) = &self.base_path {
635 if cli_base_path.is_empty() {
636 return None;
638 }
639 return Some(cli_base_path.clone());
640 }
641
642 parser.get_base_path()
644 }
645
646 async fn build_mock_config(&self) -> MockIntegrationConfig {
648 if MockServerDetector::looks_like_mock_server(&self.target) {
650 if let Ok(info) = MockServerDetector::detect(&self.target).await {
652 if info.is_mockforge {
653 TerminalReporter::print_success(&format!(
654 "Detected MockForge server (version: {})",
655 info.version.as_deref().unwrap_or("unknown")
656 ));
657 return MockIntegrationConfig::mock_server();
658 }
659 }
660 }
661 MockIntegrationConfig::real_api()
662 }
663
664 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
666 if !self.crud_flow {
667 return None;
668 }
669
670 if let Some(config_path) = &self.flow_config {
672 match CrudFlowConfig::from_file(config_path) {
673 Ok(config) => return Some(config),
674 Err(e) => {
675 TerminalReporter::print_warning(&format!(
676 "Failed to load flow config: {}. Using auto-detection.",
677 e
678 ));
679 }
680 }
681 }
682
683 let extract_fields = self
685 .extract_fields
686 .as_ref()
687 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
688 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
689
690 Some(CrudFlowConfig {
691 flows: Vec::new(), default_extract_fields: extract_fields,
693 })
694 }
695
696 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
698 let data_file = self.data_file.as_ref()?;
699
700 let distribution = DataDistribution::from_str(&self.data_distribution)
701 .unwrap_or(DataDistribution::UniquePerVu);
702
703 let mappings = self
704 .data_mappings
705 .as_ref()
706 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
707 .unwrap_or_default();
708
709 Some(DataDrivenConfig {
710 file_path: data_file.to_string_lossy().to_string(),
711 distribution,
712 mappings,
713 csv_has_header: true,
714 per_uri_control: self.per_uri_control,
715 per_uri_columns: crate::data_driven::PerUriColumns::default(),
716 })
717 }
718
719 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
721 let error_rate = self.error_rate?;
722
723 let error_types = self
724 .error_types
725 .as_ref()
726 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
727 .unwrap_or_default();
728
729 Some(InvalidDataConfig {
730 error_rate,
731 error_types,
732 target_fields: Vec::new(),
733 })
734 }
735
736 fn build_security_config(&self) -> Option<SecurityTestConfig> {
738 if !self.security_test {
739 return None;
740 }
741
742 let categories = self
743 .security_categories
744 .as_ref()
745 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
746 .unwrap_or_else(|| {
747 let mut default = std::collections::HashSet::new();
748 default.insert(SecurityCategory::SqlInjection);
749 default.insert(SecurityCategory::Xss);
750 default
751 });
752
753 let target_fields = self
754 .security_target_fields
755 .as_ref()
756 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
757 .unwrap_or_default();
758
759 let custom_payloads_file =
760 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
761
762 Some(SecurityTestConfig {
763 enabled: true,
764 categories,
765 target_fields,
766 custom_payloads_file,
767 include_high_risk: false,
768 })
769 }
770
771 fn build_parallel_config(&self) -> Option<ParallelConfig> {
773 let count = self.parallel_create?;
774
775 Some(ParallelConfig::new(count))
776 }
777
778 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
780 let Some(ref wafbench_dir) = self.wafbench_dir else {
781 return Vec::new();
782 };
783
784 let mut loader = WafBenchLoader::new();
785
786 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
787 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
788 return Vec::new();
789 }
790
791 let stats = loader.stats();
792
793 if stats.files_processed == 0 {
794 TerminalReporter::print_warning(&format!(
795 "No WAFBench YAML files found matching '{}'",
796 wafbench_dir
797 ));
798 if !stats.parse_errors.is_empty() {
800 TerminalReporter::print_warning("Some files were found but failed to parse:");
801 for error in &stats.parse_errors {
802 TerminalReporter::print_warning(&format!(" - {}", error));
803 }
804 }
805 return Vec::new();
806 }
807
808 TerminalReporter::print_progress(&format!(
809 "Loaded {} WAFBench files, {} test cases, {} payloads",
810 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
811 ));
812
813 for (category, count) in &stats.by_category {
815 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
816 }
817
818 for error in &stats.parse_errors {
820 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
821 }
822
823 loader.to_security_payloads()
824 }
825
826 fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
828 let mut enhanced_script = base_script.to_string();
829 let mut additional_code = String::new();
830
831 if let Some(config) = self.build_data_driven_config() {
833 TerminalReporter::print_progress("Adding data-driven testing support...");
834 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
835 additional_code.push('\n');
836 TerminalReporter::print_success("Data-driven testing enabled");
837 }
838
839 if let Some(config) = self.build_invalid_data_config() {
841 TerminalReporter::print_progress("Adding invalid data testing support...");
842 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
843 additional_code.push('\n');
844 additional_code
845 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
846 additional_code.push('\n');
847 additional_code
848 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
849 additional_code.push('\n');
850 TerminalReporter::print_success(&format!(
851 "Invalid data testing enabled ({}% error rate)",
852 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
853 ));
854 }
855
856 let security_config = self.build_security_config();
858 let wafbench_payloads = self.load_wafbench_payloads();
859
860 if security_config.is_some() || !wafbench_payloads.is_empty() {
861 TerminalReporter::print_progress("Adding security testing support...");
862
863 let mut payload_list: Vec<SecurityPayload> = Vec::new();
865
866 if let Some(ref config) = security_config {
867 payload_list.extend(SecurityPayloads::get_payloads(config));
868 }
869
870 if !wafbench_payloads.is_empty() {
872 TerminalReporter::print_progress(&format!(
873 "Loading {} WAFBench attack patterns...",
874 wafbench_payloads.len()
875 ));
876 payload_list.extend(wafbench_payloads);
877 }
878
879 let target_fields =
880 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
881
882 additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
883 &payload_list,
884 self.wafbench_cycle_all,
885 ));
886 additional_code.push('\n');
887 additional_code
888 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
889 additional_code.push('\n');
890 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
891 additional_code.push('\n');
892
893 let mode = if self.wafbench_cycle_all {
894 "cycle-all"
895 } else {
896 "random"
897 };
898 TerminalReporter::print_success(&format!(
899 "Security testing enabled ({} payloads, {} mode)",
900 payload_list.len(),
901 mode
902 ));
903 }
904
905 if let Some(config) = self.build_parallel_config() {
907 TerminalReporter::print_progress("Adding parallel execution support...");
908 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
909 additional_code.push('\n');
910 TerminalReporter::print_success(&format!(
911 "Parallel execution enabled (count: {})",
912 config.count
913 ));
914 }
915
916 if !additional_code.is_empty() {
918 if let Some(import_end) = enhanced_script.find("export const options") {
920 enhanced_script.insert_str(
921 import_end,
922 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
923 );
924 }
925 }
926
927 Ok(enhanced_script)
928 }
929
930 async fn execute_sequential_specs(&self) -> Result<()> {
932 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
933
934 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
936
937 if !self.spec.is_empty() {
938 let specs = load_specs_from_files(self.spec.clone())
939 .await
940 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
941 all_specs.extend(specs);
942 }
943
944 if let Some(spec_dir) = &self.spec_dir {
945 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
946 BenchError::Other(format!("Failed to load specs from directory: {}", e))
947 })?;
948 all_specs.extend(dir_specs);
949 }
950
951 if all_specs.is_empty() {
952 return Err(BenchError::Other(
953 "No spec files found for sequential execution".to_string(),
954 ));
955 }
956
957 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
958
959 let execution_order = if let Some(config_path) = &self.dependency_config {
961 TerminalReporter::print_progress("Loading dependency configuration...");
962 let config = SpecDependencyConfig::from_file(config_path)?;
963
964 if !config.disable_auto_detect && config.execution_order.is_empty() {
965 self.detect_and_sort_specs(&all_specs)?
967 } else {
968 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
970 }
971 } else {
972 self.detect_and_sort_specs(&all_specs)?
974 };
975
976 TerminalReporter::print_success(&format!(
977 "Execution order: {}",
978 execution_order
979 .iter()
980 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
981 .collect::<Vec<_>>()
982 .join(" → ")
983 ));
984
985 let mut extracted_values = ExtractedValues::new();
987 let total_specs = execution_order.len();
988
989 for (index, spec_path) in execution_order.iter().enumerate() {
990 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
991
992 TerminalReporter::print_progress(&format!(
993 "[{}/{}] Executing spec: {}",
994 index + 1,
995 total_specs,
996 spec_name
997 ));
998
999 let spec = all_specs
1001 .iter()
1002 .find(|(p, _)| {
1003 p == spec_path
1004 || p.file_name() == spec_path.file_name()
1005 || p.file_name() == Some(spec_path.as_os_str())
1006 })
1007 .map(|(_, s)| s.clone())
1008 .ok_or_else(|| {
1009 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1010 })?;
1011
1012 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1014
1015 extracted_values.merge(&new_values);
1017
1018 TerminalReporter::print_success(&format!(
1019 "[{}/{}] Completed: {} (extracted {} values)",
1020 index + 1,
1021 total_specs,
1022 spec_name,
1023 new_values.values.len()
1024 ));
1025 }
1026
1027 TerminalReporter::print_success(&format!(
1028 "Sequential execution complete: {} specs executed",
1029 total_specs
1030 ));
1031
1032 Ok(())
1033 }
1034
1035 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1037 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1038
1039 let mut detector = DependencyDetector::new();
1040 let dependencies = detector.detect_dependencies(specs);
1041
1042 if dependencies.is_empty() {
1043 TerminalReporter::print_progress("No dependencies detected, using file order");
1044 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1045 }
1046
1047 TerminalReporter::print_progress(&format!(
1048 "Detected {} cross-spec dependencies",
1049 dependencies.len()
1050 ));
1051
1052 for dep in &dependencies {
1053 TerminalReporter::print_progress(&format!(
1054 " {} → {} (via field '{}')",
1055 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1056 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1057 dep.field_name
1058 ));
1059 }
1060
1061 topological_sort(specs, &dependencies)
1062 }
1063
1064 async fn execute_single_spec(
1066 &self,
1067 spec: &OpenApiSpec,
1068 spec_name: &str,
1069 _external_values: &ExtractedValues,
1070 ) -> Result<ExtractedValues> {
1071 let parser = SpecParser::from_spec(spec.clone());
1072
1073 if self.crud_flow {
1075 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1077 } else {
1078 self.execute_standard_spec(&parser, spec_name).await?;
1080 Ok(ExtractedValues::new())
1081 }
1082 }
1083
1084 async fn execute_crud_flow_with_extraction(
1086 &self,
1087 parser: &SpecParser,
1088 spec_name: &str,
1089 ) -> Result<ExtractedValues> {
1090 let operations = parser.get_operations();
1091 let flows = CrudFlowDetector::detect_flows(&operations);
1092
1093 if flows.is_empty() {
1094 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1095 return Ok(ExtractedValues::new());
1096 }
1097
1098 TerminalReporter::print_progress(&format!(
1099 " {} CRUD flow(s) in {}",
1100 flows.len(),
1101 spec_name
1102 ));
1103
1104 let mut handlebars = handlebars::Handlebars::new();
1106 handlebars.register_helper(
1108 "json",
1109 Box::new(
1110 |h: &handlebars::Helper,
1111 _: &handlebars::Handlebars,
1112 _: &handlebars::Context,
1113 _: &mut handlebars::RenderContext,
1114 out: &mut dyn handlebars::Output|
1115 -> handlebars::HelperResult {
1116 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1117 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1118 Ok(())
1119 },
1120 ),
1121 );
1122 let template = include_str!("templates/k6_crud_flow.hbs");
1123
1124 let custom_headers = self.parse_headers()?;
1125 let config = self.build_crud_flow_config().unwrap_or_default();
1126
1127 let param_overrides = if let Some(params_file) = &self.params_file {
1129 let overrides = ParameterOverrides::from_file(params_file)?;
1130 Some(overrides)
1131 } else {
1132 None
1133 };
1134
1135 let duration_secs = Self::parse_duration(&self.duration)?;
1137 let scenario =
1138 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1139 let stages = scenario.generate_stages(duration_secs, self.vus);
1140
1141 let api_base_path = self.resolve_base_path(parser);
1143
1144 let mut all_headers = custom_headers.clone();
1146 if let Some(auth) = &self.auth {
1147 all_headers.insert("Authorization".to_string(), auth.clone());
1148 }
1149 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1150
1151 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1153
1154 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1155 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1156 serde_json::json!({
1157 "name": sanitized_name.clone(),
1158 "display_name": f.name,
1159 "base_path": f.base_path,
1160 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1161 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1163 let method_raw = if !parts.is_empty() {
1164 parts[0].to_uppercase()
1165 } else {
1166 "GET".to_string()
1167 };
1168 let method = if !parts.is_empty() {
1169 let m = parts[0].to_lowercase();
1170 if m == "delete" { "del".to_string() } else { m }
1172 } else {
1173 "get".to_string()
1174 };
1175 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1176 let path = if let Some(ref bp) = api_base_path {
1178 format!("{}{}", bp, raw_path)
1179 } else {
1180 raw_path.to_string()
1181 };
1182 let is_get_or_head = method == "get" || method == "head";
1183 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1185
1186 let body_value = if has_body {
1188 param_overrides.as_ref()
1189 .map(|po| po.get_for_operation(None, &method_raw, &raw_path))
1190 .and_then(|oo| oo.body)
1191 .unwrap_or_else(|| serde_json::json!({}))
1192 } else {
1193 serde_json::json!({})
1194 };
1195
1196 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1198
1199 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1201 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1202
1203 serde_json::json!({
1204 "operation": s.operation,
1205 "method": method,
1206 "path": path,
1207 "extract": s.extract,
1208 "use_values": s.use_values,
1209 "use_body": s.use_body,
1210 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1211 "inject_attacks": s.inject_attacks,
1212 "attack_types": s.attack_types,
1213 "description": s.description,
1214 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1215 "is_get_or_head": is_get_or_head,
1216 "has_body": has_body,
1217 "body": processed_body.value,
1218 "body_is_dynamic": body_is_dynamic,
1219 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1220 })
1221 }).collect::<Vec<_>>(),
1222 })
1223 }).collect();
1224
1225 for flow_data in &flows_data {
1227 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1228 for step in steps {
1229 if let Some(placeholders_arr) =
1230 step.get("_placeholders").and_then(|p| p.as_array())
1231 {
1232 for p_str in placeholders_arr {
1233 if let Some(p_name) = p_str.as_str() {
1234 match p_name {
1235 "VU" => {
1236 all_placeholders.insert(DynamicPlaceholder::VU);
1237 }
1238 "Iteration" => {
1239 all_placeholders.insert(DynamicPlaceholder::Iteration);
1240 }
1241 "Timestamp" => {
1242 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1243 }
1244 "UUID" => {
1245 all_placeholders.insert(DynamicPlaceholder::UUID);
1246 }
1247 "Random" => {
1248 all_placeholders.insert(DynamicPlaceholder::Random);
1249 }
1250 "Counter" => {
1251 all_placeholders.insert(DynamicPlaceholder::Counter);
1252 }
1253 "Date" => {
1254 all_placeholders.insert(DynamicPlaceholder::Date);
1255 }
1256 "VuIter" => {
1257 all_placeholders.insert(DynamicPlaceholder::VuIter);
1258 }
1259 _ => {}
1260 }
1261 }
1262 }
1263 }
1264 }
1265 }
1266 }
1267
1268 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1270 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1271
1272 let data = serde_json::json!({
1273 "base_url": self.target,
1274 "flows": flows_data,
1275 "extract_fields": config.default_extract_fields,
1276 "duration_secs": duration_secs,
1277 "max_vus": self.vus,
1278 "auth_header": self.auth,
1279 "custom_headers": custom_headers,
1280 "skip_tls_verify": self.skip_tls_verify,
1281 "stages": stages.iter().map(|s| serde_json::json!({
1283 "duration": s.duration,
1284 "target": s.target,
1285 })).collect::<Vec<_>>(),
1286 "threshold_percentile": self.threshold_percentile,
1287 "threshold_ms": self.threshold_ms,
1288 "max_error_rate": self.max_error_rate,
1289 "headers": headers_json,
1290 "dynamic_imports": required_imports,
1291 "dynamic_globals": required_globals,
1292 });
1293
1294 let script = handlebars
1295 .render_template(template, &data)
1296 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1297
1298 let script_path =
1300 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1301
1302 std::fs::create_dir_all(self.output.clone())?;
1303 std::fs::write(&script_path, &script)?;
1304
1305 if !self.generate_only {
1306 let executor = K6Executor::new()?;
1307 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1308 std::fs::create_dir_all(&output_dir)?;
1309
1310 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1311 }
1312
1313 Ok(ExtractedValues::new())
1316 }
1317
1318 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1320 let mut operations = if let Some(filter) = &self.operations {
1321 parser.filter_operations(filter)?
1322 } else {
1323 parser.get_operations()
1324 };
1325
1326 if let Some(exclude) = &self.exclude_operations {
1327 operations = parser.exclude_operations(operations, exclude)?;
1328 }
1329
1330 if operations.is_empty() {
1331 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1332 return Ok(());
1333 }
1334
1335 TerminalReporter::print_progress(&format!(
1336 " {} operations in {}",
1337 operations.len(),
1338 spec_name
1339 ));
1340
1341 let templates: Vec<_> = operations
1343 .iter()
1344 .map(RequestGenerator::generate_template)
1345 .collect::<Result<Vec<_>>>()?;
1346
1347 let custom_headers = self.parse_headers()?;
1349
1350 let base_path = self.resolve_base_path(parser);
1352
1353 let scenario =
1355 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1356
1357 let k6_config = K6Config {
1358 target_url: self.target.clone(),
1359 base_path,
1360 scenario,
1361 duration_secs: Self::parse_duration(&self.duration)?,
1362 max_vus: self.vus,
1363 threshold_percentile: self.threshold_percentile.clone(),
1364 threshold_ms: self.threshold_ms,
1365 max_error_rate: self.max_error_rate,
1366 auth_header: self.auth.clone(),
1367 custom_headers,
1368 skip_tls_verify: self.skip_tls_verify,
1369 };
1370
1371 let generator = K6ScriptGenerator::new(k6_config, templates);
1372 let script = generator.generate()?;
1373
1374 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1376
1377 std::fs::create_dir_all(self.output.clone())?;
1378 std::fs::write(&script_path, &script)?;
1379
1380 if !self.generate_only {
1381 let executor = K6Executor::new()?;
1382 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1383 std::fs::create_dir_all(&output_dir)?;
1384
1385 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1386 }
1387
1388 Ok(())
1389 }
1390
1391 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1393 let config = self.build_crud_flow_config().unwrap_or_default();
1395
1396 let flows = if !config.flows.is_empty() {
1398 TerminalReporter::print_progress("Using custom flow configuration...");
1399 config.flows.clone()
1400 } else {
1401 TerminalReporter::print_progress("Detecting CRUD operations...");
1402 let operations = parser.get_operations();
1403 CrudFlowDetector::detect_flows(&operations)
1404 };
1405
1406 if flows.is_empty() {
1407 return Err(BenchError::Other(
1408 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1409 ));
1410 }
1411
1412 if config.flows.is_empty() {
1413 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1414 } else {
1415 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1416 }
1417
1418 for flow in &flows {
1419 TerminalReporter::print_progress(&format!(
1420 " - {}: {} steps",
1421 flow.name,
1422 flow.steps.len()
1423 ));
1424 }
1425
1426 let mut handlebars = handlebars::Handlebars::new();
1428 handlebars.register_helper(
1430 "json",
1431 Box::new(
1432 |h: &handlebars::Helper,
1433 _: &handlebars::Handlebars,
1434 _: &handlebars::Context,
1435 _: &mut handlebars::RenderContext,
1436 out: &mut dyn handlebars::Output|
1437 -> handlebars::HelperResult {
1438 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1439 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1440 Ok(())
1441 },
1442 ),
1443 );
1444 let template = include_str!("templates/k6_crud_flow.hbs");
1445
1446 let custom_headers = self.parse_headers()?;
1447
1448 let param_overrides = if let Some(params_file) = &self.params_file {
1450 TerminalReporter::print_progress("Loading parameter overrides...");
1451 let overrides = ParameterOverrides::from_file(params_file)?;
1452 TerminalReporter::print_success(&format!(
1453 "Loaded parameter overrides ({} operation-specific, {} defaults)",
1454 overrides.operations.len(),
1455 if overrides.defaults.is_empty() { 0 } else { 1 }
1456 ));
1457 Some(overrides)
1458 } else {
1459 None
1460 };
1461
1462 let duration_secs = Self::parse_duration(&self.duration)?;
1464 let scenario =
1465 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1466 let stages = scenario.generate_stages(duration_secs, self.vus);
1467
1468 let api_base_path = self.resolve_base_path(parser);
1470 if let Some(ref bp) = api_base_path {
1471 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1472 }
1473
1474 let mut all_headers = custom_headers.clone();
1476 if let Some(auth) = &self.auth {
1477 all_headers.insert("Authorization".to_string(), auth.clone());
1478 }
1479 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1480
1481 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1483
1484 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1485 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1487 serde_json::json!({
1488 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1491 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1492 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1494 let method_raw = if !parts.is_empty() {
1495 parts[0].to_uppercase()
1496 } else {
1497 "GET".to_string()
1498 };
1499 let method = if !parts.is_empty() {
1500 let m = parts[0].to_lowercase();
1501 if m == "delete" { "del".to_string() } else { m }
1503 } else {
1504 "get".to_string()
1505 };
1506 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1507 let path = if let Some(ref bp) = api_base_path {
1509 format!("{}{}", bp, raw_path)
1510 } else {
1511 raw_path.to_string()
1512 };
1513 let is_get_or_head = method == "get" || method == "head";
1514 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1516
1517 let body_value = if has_body {
1519 param_overrides.as_ref()
1520 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1521 .and_then(|oo| oo.body)
1522 .unwrap_or_else(|| serde_json::json!({}))
1523 } else {
1524 serde_json::json!({})
1525 };
1526
1527 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1529 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1534 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1535
1536 serde_json::json!({
1537 "operation": s.operation,
1538 "method": method,
1539 "path": path,
1540 "extract": s.extract,
1541 "use_values": s.use_values,
1542 "use_body": s.use_body,
1543 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1544 "inject_attacks": s.inject_attacks,
1545 "attack_types": s.attack_types,
1546 "description": s.description,
1547 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1548 "is_get_or_head": is_get_or_head,
1549 "has_body": has_body,
1550 "body": processed_body.value,
1551 "body_is_dynamic": body_is_dynamic,
1552 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1553 })
1554 }).collect::<Vec<_>>(),
1555 })
1556 }).collect();
1557
1558 for flow_data in &flows_data {
1560 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1561 for step in steps {
1562 if let Some(placeholders_arr) =
1563 step.get("_placeholders").and_then(|p| p.as_array())
1564 {
1565 for p_str in placeholders_arr {
1566 if let Some(p_name) = p_str.as_str() {
1567 match p_name {
1569 "VU" => {
1570 all_placeholders.insert(DynamicPlaceholder::VU);
1571 }
1572 "Iteration" => {
1573 all_placeholders.insert(DynamicPlaceholder::Iteration);
1574 }
1575 "Timestamp" => {
1576 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1577 }
1578 "UUID" => {
1579 all_placeholders.insert(DynamicPlaceholder::UUID);
1580 }
1581 "Random" => {
1582 all_placeholders.insert(DynamicPlaceholder::Random);
1583 }
1584 "Counter" => {
1585 all_placeholders.insert(DynamicPlaceholder::Counter);
1586 }
1587 "Date" => {
1588 all_placeholders.insert(DynamicPlaceholder::Date);
1589 }
1590 "VuIter" => {
1591 all_placeholders.insert(DynamicPlaceholder::VuIter);
1592 }
1593 _ => {}
1594 }
1595 }
1596 }
1597 }
1598 }
1599 }
1600 }
1601
1602 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1604 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1605
1606 let invalid_data_config = self.build_invalid_data_config();
1608 let error_injection_enabled = invalid_data_config.is_some();
1609 let error_rate = self.error_rate.unwrap_or(0.0);
1610 let error_types: Vec<String> = invalid_data_config
1611 .as_ref()
1612 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1613 .unwrap_or_default();
1614
1615 if error_injection_enabled {
1616 TerminalReporter::print_progress(&format!(
1617 "Error injection enabled ({}% rate)",
1618 (error_rate * 100.0) as u32
1619 ));
1620 }
1621
1622 let data = serde_json::json!({
1623 "base_url": self.target,
1624 "flows": flows_data,
1625 "extract_fields": config.default_extract_fields,
1626 "duration_secs": duration_secs,
1627 "max_vus": self.vus,
1628 "auth_header": self.auth,
1629 "custom_headers": custom_headers,
1630 "skip_tls_verify": self.skip_tls_verify,
1631 "stages": stages.iter().map(|s| serde_json::json!({
1633 "duration": s.duration,
1634 "target": s.target,
1635 })).collect::<Vec<_>>(),
1636 "threshold_percentile": self.threshold_percentile,
1637 "threshold_ms": self.threshold_ms,
1638 "max_error_rate": self.max_error_rate,
1639 "headers": headers_json,
1640 "dynamic_imports": required_imports,
1641 "dynamic_globals": required_globals,
1642 "error_injection_enabled": error_injection_enabled,
1644 "error_rate": error_rate,
1645 "error_types": error_types,
1646 });
1647
1648 let script = handlebars
1649 .render_template(template, &data)
1650 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1651
1652 TerminalReporter::print_progress("Validating CRUD flow script...");
1654 let validation_errors = K6ScriptGenerator::validate_script(&script);
1655 if !validation_errors.is_empty() {
1656 TerminalReporter::print_error("CRUD flow script validation failed");
1657 for error in &validation_errors {
1658 eprintln!(" {}", error);
1659 }
1660 return Err(BenchError::Other(format!(
1661 "CRUD flow script validation failed with {} error(s)",
1662 validation_errors.len()
1663 )));
1664 }
1665
1666 TerminalReporter::print_success("CRUD flow script generated");
1667
1668 let script_path = if let Some(output) = &self.script_output {
1670 output.clone()
1671 } else {
1672 self.output.join("k6-crud-flow-script.js")
1673 };
1674
1675 if let Some(parent) = script_path.parent() {
1676 std::fs::create_dir_all(parent)?;
1677 }
1678 std::fs::write(&script_path, &script)?;
1679 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1680
1681 if self.generate_only {
1682 println!("\nScript generated successfully. Run it with:");
1683 println!(" k6 run {}", script_path.display());
1684 return Ok(());
1685 }
1686
1687 TerminalReporter::print_progress("Executing CRUD flow test...");
1689 let executor = K6Executor::new()?;
1690 std::fs::create_dir_all(&self.output)?;
1691
1692 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1693
1694 let duration_secs = Self::parse_duration(&self.duration)?;
1695 TerminalReporter::print_summary(&results, duration_secs);
1696
1697 Ok(())
1698 }
1699
1700 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
1702 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
1703
1704 let mut config = OwaspApiConfig::new()
1706 .with_auth_header(&self.owasp_auth_header)
1707 .with_verbose(self.verbose)
1708 .with_insecure(self.skip_tls_verify)
1709 .with_concurrency(self.vus as usize)
1710 .with_iterations(self.owasp_iterations as usize);
1711
1712 if let Some(ref token) = self.owasp_auth_token {
1714 config = config.with_valid_auth_token(token);
1715 }
1716
1717 if let Some(ref cats_str) = self.owasp_categories {
1719 let categories: Vec<OwaspCategory> = cats_str
1720 .split(',')
1721 .filter_map(|s| {
1722 let trimmed = s.trim();
1723 match trimmed.parse::<OwaspCategory>() {
1724 Ok(cat) => Some(cat),
1725 Err(e) => {
1726 TerminalReporter::print_warning(&e);
1727 None
1728 }
1729 }
1730 })
1731 .collect();
1732
1733 if !categories.is_empty() {
1734 config = config.with_categories(categories);
1735 }
1736 }
1737
1738 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
1740 config.admin_paths_file = Some(admin_paths_file.clone());
1741 if let Err(e) = config.load_admin_paths() {
1742 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
1743 }
1744 }
1745
1746 if let Some(ref id_fields_str) = self.owasp_id_fields {
1748 let id_fields: Vec<String> = id_fields_str
1749 .split(',')
1750 .map(|s| s.trim().to_string())
1751 .filter(|s| !s.is_empty())
1752 .collect();
1753 if !id_fields.is_empty() {
1754 config = config.with_id_fields(id_fields);
1755 }
1756 }
1757
1758 if let Some(ref report_path) = self.owasp_report {
1760 config = config.with_report_path(report_path);
1761 }
1762 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
1763 config = config.with_report_format(format);
1764 }
1765
1766 let categories = config.categories_to_test();
1768 TerminalReporter::print_success(&format!(
1769 "Testing {} OWASP categories: {}",
1770 categories.len(),
1771 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
1772 ));
1773
1774 if config.valid_auth_token.is_some() {
1775 TerminalReporter::print_progress("Using provided auth token for baseline requests");
1776 }
1777
1778 TerminalReporter::print_progress("Generating OWASP security test script...");
1780 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
1781
1782 let script = generator.generate()?;
1784 TerminalReporter::print_success("OWASP security test script generated");
1785
1786 let script_path = if let Some(output) = &self.script_output {
1788 output.clone()
1789 } else {
1790 self.output.join("k6-owasp-security-test.js")
1791 };
1792
1793 if let Some(parent) = script_path.parent() {
1794 std::fs::create_dir_all(parent)?;
1795 }
1796 std::fs::write(&script_path, &script)?;
1797 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1798
1799 if self.generate_only {
1801 println!("\nOWASP security test script generated. Run it with:");
1802 println!(" k6 run {}", script_path.display());
1803 return Ok(());
1804 }
1805
1806 TerminalReporter::print_progress("Executing OWASP security tests...");
1808 let executor = K6Executor::new()?;
1809 std::fs::create_dir_all(&self.output)?;
1810
1811 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1812
1813 let duration_secs = Self::parse_duration(&self.duration)?;
1814 TerminalReporter::print_summary(&results, duration_secs);
1815
1816 println!("\nOWASP security test results saved to: {}", self.output.display());
1817
1818 Ok(())
1819 }
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824 use super::*;
1825
1826 #[test]
1827 fn test_parse_duration() {
1828 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
1829 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
1830 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
1831 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
1832 }
1833
1834 #[test]
1835 fn test_parse_duration_invalid() {
1836 assert!(BenchCommand::parse_duration("invalid").is_err());
1837 assert!(BenchCommand::parse_duration("30x").is_err());
1838 }
1839
1840 #[test]
1841 fn test_parse_headers() {
1842 let cmd = BenchCommand {
1843 spec: vec![PathBuf::from("test.yaml")],
1844 spec_dir: None,
1845 merge_conflicts: "error".to_string(),
1846 spec_mode: "merge".to_string(),
1847 dependency_config: None,
1848 target: "http://localhost".to_string(),
1849 base_path: None,
1850 duration: "1m".to_string(),
1851 vus: 10,
1852 scenario: "ramp-up".to_string(),
1853 operations: None,
1854 exclude_operations: None,
1855 auth: None,
1856 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
1857 output: PathBuf::from("output"),
1858 generate_only: false,
1859 script_output: None,
1860 threshold_percentile: "p(95)".to_string(),
1861 threshold_ms: 500,
1862 max_error_rate: 0.05,
1863 verbose: false,
1864 skip_tls_verify: false,
1865 targets_file: None,
1866 max_concurrency: None,
1867 results_format: "both".to_string(),
1868 params_file: None,
1869 crud_flow: false,
1870 flow_config: None,
1871 extract_fields: None,
1872 parallel_create: None,
1873 data_file: None,
1874 data_distribution: "unique-per-vu".to_string(),
1875 data_mappings: None,
1876 per_uri_control: false,
1877 error_rate: None,
1878 error_types: None,
1879 security_test: false,
1880 security_payloads: None,
1881 security_categories: None,
1882 security_target_fields: None,
1883 wafbench_dir: None,
1884 wafbench_cycle_all: false,
1885 owasp_api_top10: false,
1886 owasp_categories: None,
1887 owasp_auth_header: "Authorization".to_string(),
1888 owasp_auth_token: None,
1889 owasp_admin_paths: None,
1890 owasp_id_fields: None,
1891 owasp_report: None,
1892 owasp_report_format: "json".to_string(),
1893 owasp_iterations: 1,
1894 };
1895
1896 let headers = cmd.parse_headers().unwrap();
1897 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
1898 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
1899 }
1900
1901 #[test]
1902 fn test_get_spec_display_name() {
1903 let cmd = BenchCommand {
1904 spec: vec![PathBuf::from("test.yaml")],
1905 spec_dir: None,
1906 merge_conflicts: "error".to_string(),
1907 spec_mode: "merge".to_string(),
1908 dependency_config: None,
1909 target: "http://localhost".to_string(),
1910 base_path: None,
1911 duration: "1m".to_string(),
1912 vus: 10,
1913 scenario: "ramp-up".to_string(),
1914 operations: None,
1915 exclude_operations: None,
1916 auth: None,
1917 headers: None,
1918 output: PathBuf::from("output"),
1919 generate_only: false,
1920 script_output: None,
1921 threshold_percentile: "p(95)".to_string(),
1922 threshold_ms: 500,
1923 max_error_rate: 0.05,
1924 verbose: false,
1925 skip_tls_verify: false,
1926 targets_file: None,
1927 max_concurrency: None,
1928 results_format: "both".to_string(),
1929 params_file: None,
1930 crud_flow: false,
1931 flow_config: None,
1932 extract_fields: None,
1933 parallel_create: None,
1934 data_file: None,
1935 data_distribution: "unique-per-vu".to_string(),
1936 data_mappings: None,
1937 per_uri_control: false,
1938 error_rate: None,
1939 error_types: None,
1940 security_test: false,
1941 security_payloads: None,
1942 security_categories: None,
1943 security_target_fields: None,
1944 wafbench_dir: None,
1945 wafbench_cycle_all: false,
1946 owasp_api_top10: false,
1947 owasp_categories: None,
1948 owasp_auth_header: "Authorization".to_string(),
1949 owasp_auth_token: None,
1950 owasp_admin_paths: None,
1951 owasp_id_fields: None,
1952 owasp_report: None,
1953 owasp_report_format: "json".to_string(),
1954 owasp_iterations: 1,
1955 };
1956
1957 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
1958
1959 let cmd_multi = BenchCommand {
1961 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
1962 spec_dir: None,
1963 merge_conflicts: "error".to_string(),
1964 spec_mode: "merge".to_string(),
1965 dependency_config: None,
1966 target: "http://localhost".to_string(),
1967 base_path: None,
1968 duration: "1m".to_string(),
1969 vus: 10,
1970 scenario: "ramp-up".to_string(),
1971 operations: None,
1972 exclude_operations: None,
1973 auth: None,
1974 headers: None,
1975 output: PathBuf::from("output"),
1976 generate_only: false,
1977 script_output: None,
1978 threshold_percentile: "p(95)".to_string(),
1979 threshold_ms: 500,
1980 max_error_rate: 0.05,
1981 verbose: false,
1982 skip_tls_verify: false,
1983 targets_file: None,
1984 max_concurrency: None,
1985 results_format: "both".to_string(),
1986 params_file: None,
1987 crud_flow: false,
1988 flow_config: None,
1989 extract_fields: None,
1990 parallel_create: None,
1991 data_file: None,
1992 data_distribution: "unique-per-vu".to_string(),
1993 data_mappings: None,
1994 per_uri_control: false,
1995 error_rate: None,
1996 error_types: None,
1997 security_test: false,
1998 security_payloads: None,
1999 security_categories: None,
2000 security_target_fields: None,
2001 wafbench_dir: None,
2002 wafbench_cycle_all: false,
2003 owasp_api_top10: false,
2004 owasp_categories: None,
2005 owasp_auth_header: "Authorization".to_string(),
2006 owasp_auth_token: None,
2007 owasp_admin_paths: None,
2008 owasp_id_fields: None,
2009 owasp_report: None,
2010 owasp_report_format: "json".to_string(),
2011 owasp_iterations: 1,
2012 };
2013
2014 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2015 }
2016}