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::{Path, PathBuf};
35use std::str::FromStr;
36
37pub fn parse_header_string(input: &str) -> Result<HashMap<String, String>> {
45 let mut headers = HashMap::new();
46
47 for pair in input.split(',') {
48 let parts: Vec<&str> = pair.splitn(2, ':').collect();
49 if parts.len() != 2 {
50 return Err(BenchError::Other(format!(
51 "Invalid header format: '{}'. Expected 'Key:Value'",
52 pair
53 )));
54 }
55 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
56 }
57
58 Ok(headers)
59}
60
61pub struct BenchCommand {
63 pub spec: Vec<PathBuf>,
65 pub spec_dir: Option<PathBuf>,
67 pub merge_conflicts: String,
69 pub spec_mode: String,
71 pub dependency_config: Option<PathBuf>,
73 pub target: String,
74 pub base_path: Option<String>,
77 pub duration: String,
78 pub vus: u32,
79 pub scenario: String,
80 pub operations: Option<String>,
81 pub exclude_operations: Option<String>,
85 pub auth: Option<String>,
86 pub headers: Option<String>,
87 pub output: PathBuf,
88 pub generate_only: bool,
89 pub script_output: Option<PathBuf>,
90 pub threshold_percentile: String,
91 pub threshold_ms: u64,
92 pub max_error_rate: f64,
93 pub verbose: bool,
94 pub skip_tls_verify: bool,
95 pub targets_file: Option<PathBuf>,
97 pub max_concurrency: Option<u32>,
99 pub results_format: String,
101 pub params_file: Option<PathBuf>,
106
107 pub crud_flow: bool,
110 pub flow_config: Option<PathBuf>,
112 pub extract_fields: Option<String>,
114
115 pub parallel_create: Option<u32>,
118
119 pub data_file: Option<PathBuf>,
122 pub data_distribution: String,
124 pub data_mappings: Option<String>,
126 pub per_uri_control: bool,
128
129 pub error_rate: Option<f64>,
132 pub error_types: Option<String>,
134
135 pub security_test: bool,
138 pub security_payloads: Option<PathBuf>,
140 pub security_categories: Option<String>,
142 pub security_target_fields: Option<String>,
144
145 pub wafbench_dir: Option<String>,
148 pub wafbench_cycle_all: bool,
150
151 pub conformance: bool,
154 pub conformance_api_key: Option<String>,
156 pub conformance_basic_auth: Option<String>,
158 pub conformance_report: PathBuf,
160 pub conformance_categories: Option<String>,
162 pub conformance_report_format: String,
164 pub conformance_headers: Vec<String>,
167 pub conformance_all_operations: bool,
170 pub conformance_custom: Option<PathBuf>,
172 pub conformance_delay_ms: u64,
175 pub use_k6: bool,
177 pub conformance_custom_filter: Option<String>,
181 pub export_requests: bool,
184
185 pub owasp_api_top10: bool,
188 pub owasp_categories: Option<String>,
190 pub owasp_auth_header: String,
192 pub owasp_auth_token: Option<String>,
194 pub owasp_admin_paths: Option<PathBuf>,
196 pub owasp_id_fields: Option<String>,
198 pub owasp_report: Option<PathBuf>,
200 pub owasp_report_format: String,
202 pub owasp_iterations: u32,
204}
205
206impl BenchCommand {
207 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
209 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
210
211 if !self.spec.is_empty() {
213 let specs = load_specs_from_files(self.spec.clone())
214 .await
215 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
216 all_specs.extend(specs);
217 }
218
219 if let Some(spec_dir) = &self.spec_dir {
221 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
222 BenchError::Other(format!("Failed to load specs from directory: {}", e))
223 })?;
224 all_specs.extend(dir_specs);
225 }
226
227 if all_specs.is_empty() {
228 return Err(BenchError::Other(
229 "No spec files provided. Use --spec or --spec-dir.".to_string(),
230 ));
231 }
232
233 if all_specs.len() == 1 {
235 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
237 }
238
239 let conflict_strategy = match self.merge_conflicts.as_str() {
241 "first" => ConflictStrategy::First,
242 "last" => ConflictStrategy::Last,
243 _ => ConflictStrategy::Error,
244 };
245
246 merge_specs(all_specs, conflict_strategy)
247 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
248 }
249
250 fn get_spec_display_name(&self) -> String {
252 if self.spec.len() == 1 {
253 self.spec[0].to_string_lossy().to_string()
254 } else if !self.spec.is_empty() {
255 format!("{} spec files", self.spec.len())
256 } else if let Some(dir) = &self.spec_dir {
257 format!("specs from {}", dir.display())
258 } else {
259 "no specs".to_string()
260 }
261 }
262
263 pub async fn execute(&self) -> Result<()> {
265 if let Some(targets_file) = &self.targets_file {
267 if self.conformance {
268 return self.execute_multi_target_conformance(targets_file).await;
269 }
270 return self.execute_multi_target(targets_file).await;
271 }
272
273 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
275 return self.execute_sequential_specs().await;
276 }
277
278 TerminalReporter::print_header(
281 &self.get_spec_display_name(),
282 &self.target,
283 0, &self.scenario,
285 Self::parse_duration(&self.duration)?,
286 );
287
288 if !K6Executor::is_k6_installed() {
290 TerminalReporter::print_error("k6 is not installed");
291 TerminalReporter::print_warning(
292 "Install k6 from: https://k6.io/docs/get-started/installation/",
293 );
294 return Err(BenchError::K6NotFound);
295 }
296
297 if self.conformance {
299 return self.execute_conformance_test().await;
300 }
301
302 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
304 let merged_spec = self.load_and_merge_specs().await?;
305 let parser = SpecParser::from_spec(merged_spec);
306 if self.spec.len() > 1 || self.spec_dir.is_some() {
307 TerminalReporter::print_success(&format!(
308 "Loaded and merged {} specification(s)",
309 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
310 ));
311 } else {
312 TerminalReporter::print_success("Specification loaded");
313 }
314
315 let mock_config = self.build_mock_config().await;
317 if mock_config.is_mock_server {
318 TerminalReporter::print_progress("Mock server integration enabled");
319 }
320
321 if self.crud_flow {
323 return self.execute_crud_flow(&parser).await;
324 }
325
326 if self.owasp_api_top10 {
328 return self.execute_owasp_test(&parser).await;
329 }
330
331 TerminalReporter::print_progress("Extracting API operations...");
333 let mut operations = if let Some(filter) = &self.operations {
334 parser.filter_operations(filter)?
335 } else {
336 parser.get_operations()
337 };
338
339 if let Some(exclude) = &self.exclude_operations {
341 let before_count = operations.len();
342 operations = parser.exclude_operations(operations, exclude)?;
343 let excluded_count = before_count - operations.len();
344 if excluded_count > 0 {
345 TerminalReporter::print_progress(&format!(
346 "Excluded {} operations matching '{}'",
347 excluded_count, exclude
348 ));
349 }
350 }
351
352 if operations.is_empty() {
353 return Err(BenchError::Other("No operations found in spec".to_string()));
354 }
355
356 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
357
358 let param_overrides = if let Some(params_file) = &self.params_file {
360 TerminalReporter::print_progress("Loading parameter overrides...");
361 let overrides = ParameterOverrides::from_file(params_file)?;
362 TerminalReporter::print_success(&format!(
363 "Loaded parameter overrides ({} operation-specific, {} defaults)",
364 overrides.operations.len(),
365 if overrides.defaults.is_empty() { 0 } else { 1 }
366 ));
367 Some(overrides)
368 } else {
369 None
370 };
371
372 TerminalReporter::print_progress("Generating request templates...");
374 let templates: Vec<_> = operations
375 .iter()
376 .map(|op| {
377 let op_overrides = param_overrides.as_ref().map(|po| {
378 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
379 });
380 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
381 })
382 .collect::<Result<Vec<_>>>()?;
383 TerminalReporter::print_success("Request templates generated");
384
385 let custom_headers = self.parse_headers()?;
387
388 let base_path = self.resolve_base_path(&parser);
390 if let Some(ref bp) = base_path {
391 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
392 }
393
394 TerminalReporter::print_progress("Generating k6 load test script...");
396 let scenario =
397 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
398
399 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
400
401 let k6_config = K6Config {
402 target_url: self.target.clone(),
403 base_path,
404 scenario,
405 duration_secs: Self::parse_duration(&self.duration)?,
406 max_vus: self.vus,
407 threshold_percentile: self.threshold_percentile.clone(),
408 threshold_ms: self.threshold_ms,
409 max_error_rate: self.max_error_rate,
410 auth_header: self.auth.clone(),
411 custom_headers,
412 skip_tls_verify: self.skip_tls_verify,
413 security_testing_enabled,
414 };
415
416 let generator = K6ScriptGenerator::new(k6_config, templates);
417 let mut script = generator.generate()?;
418 TerminalReporter::print_success("k6 script generated");
419
420 let has_advanced_features = self.data_file.is_some()
422 || self.error_rate.is_some()
423 || self.security_test
424 || self.parallel_create.is_some()
425 || self.wafbench_dir.is_some();
426
427 if has_advanced_features {
429 script = self.generate_enhanced_script(&script)?;
430 }
431
432 if mock_config.is_mock_server {
434 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
435 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
436 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
437
438 if let Some(import_end) = script.find("export const options") {
440 script.insert_str(
441 import_end,
442 &format!(
443 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
444 helper_code, setup_code, teardown_code
445 ),
446 );
447 }
448 }
449
450 TerminalReporter::print_progress("Validating k6 script...");
452 let validation_errors = K6ScriptGenerator::validate_script(&script);
453 if !validation_errors.is_empty() {
454 TerminalReporter::print_error("Script validation failed");
455 for error in &validation_errors {
456 eprintln!(" {}", error);
457 }
458 return Err(BenchError::Other(format!(
459 "Generated k6 script has {} validation error(s). Please check the output above.",
460 validation_errors.len()
461 )));
462 }
463 TerminalReporter::print_success("Script validation passed");
464
465 let script_path = if let Some(output) = &self.script_output {
467 output.clone()
468 } else {
469 self.output.join("k6-script.js")
470 };
471
472 if let Some(parent) = script_path.parent() {
473 std::fs::create_dir_all(parent)?;
474 }
475 std::fs::write(&script_path, &script)?;
476 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
477
478 if self.generate_only {
480 println!("\nScript generated successfully. Run it with:");
481 println!(" k6 run {}", script_path.display());
482 return Ok(());
483 }
484
485 TerminalReporter::print_progress("Executing load test...");
487 let executor = K6Executor::new()?;
488
489 std::fs::create_dir_all(&self.output)?;
490
491 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
492
493 let duration_secs = Self::parse_duration(&self.duration)?;
495 TerminalReporter::print_summary(&results, duration_secs);
496
497 println!("\nResults saved to: {}", self.output.display());
498
499 Ok(())
500 }
501
502 async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
504 TerminalReporter::print_progress("Parsing targets file...");
505 let targets = parse_targets_file(targets_file)?;
506 let num_targets = targets.len();
507 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
508
509 if targets.is_empty() {
510 return Err(BenchError::Other("No targets found in file".to_string()));
511 }
512
513 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
515 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
519 &self.get_spec_display_name(),
520 &format!("{} targets", num_targets),
521 0,
522 &self.scenario,
523 Self::parse_duration(&self.duration)?,
524 );
525
526 let executor = ParallelExecutor::new(
528 BenchCommand {
529 spec: self.spec.clone(),
531 spec_dir: self.spec_dir.clone(),
532 merge_conflicts: self.merge_conflicts.clone(),
533 spec_mode: self.spec_mode.clone(),
534 dependency_config: self.dependency_config.clone(),
535 target: self.target.clone(), base_path: self.base_path.clone(),
537 duration: self.duration.clone(),
538 vus: self.vus,
539 scenario: self.scenario.clone(),
540 operations: self.operations.clone(),
541 exclude_operations: self.exclude_operations.clone(),
542 auth: self.auth.clone(),
543 headers: self.headers.clone(),
544 output: self.output.clone(),
545 generate_only: self.generate_only,
546 script_output: self.script_output.clone(),
547 threshold_percentile: self.threshold_percentile.clone(),
548 threshold_ms: self.threshold_ms,
549 max_error_rate: self.max_error_rate,
550 verbose: self.verbose,
551 skip_tls_verify: self.skip_tls_verify,
552 targets_file: None,
553 max_concurrency: None,
554 results_format: self.results_format.clone(),
555 params_file: self.params_file.clone(),
556 crud_flow: self.crud_flow,
557 flow_config: self.flow_config.clone(),
558 extract_fields: self.extract_fields.clone(),
559 parallel_create: self.parallel_create,
560 data_file: self.data_file.clone(),
561 data_distribution: self.data_distribution.clone(),
562 data_mappings: self.data_mappings.clone(),
563 per_uri_control: self.per_uri_control,
564 error_rate: self.error_rate,
565 error_types: self.error_types.clone(),
566 security_test: self.security_test,
567 security_payloads: self.security_payloads.clone(),
568 security_categories: self.security_categories.clone(),
569 security_target_fields: self.security_target_fields.clone(),
570 wafbench_dir: self.wafbench_dir.clone(),
571 wafbench_cycle_all: self.wafbench_cycle_all,
572 owasp_api_top10: self.owasp_api_top10,
573 owasp_categories: self.owasp_categories.clone(),
574 owasp_auth_header: self.owasp_auth_header.clone(),
575 owasp_auth_token: self.owasp_auth_token.clone(),
576 owasp_admin_paths: self.owasp_admin_paths.clone(),
577 owasp_id_fields: self.owasp_id_fields.clone(),
578 owasp_report: self.owasp_report.clone(),
579 owasp_report_format: self.owasp_report_format.clone(),
580 owasp_iterations: self.owasp_iterations,
581 conformance: false,
582 conformance_api_key: None,
583 conformance_basic_auth: None,
584 conformance_report: PathBuf::from("conformance-report.json"),
585 conformance_categories: None,
586 conformance_report_format: "json".to_string(),
587 conformance_headers: vec![],
588 conformance_all_operations: false,
589 conformance_custom: None,
590 conformance_delay_ms: 0,
591 use_k6: false,
592 conformance_custom_filter: None,
593 export_requests: false,
594 },
595 targets,
596 max_concurrency,
597 );
598
599 let start_time = std::time::Instant::now();
601 let aggregated_results = executor.execute_all().await?;
602 let elapsed = start_time.elapsed();
603
604 self.report_multi_target_results(&aggregated_results, elapsed)?;
606
607 Ok(())
608 }
609
610 fn report_multi_target_results(
612 &self,
613 results: &AggregatedResults,
614 elapsed: std::time::Duration,
615 ) -> Result<()> {
616 TerminalReporter::print_multi_target_summary(results);
618
619 let total_secs = elapsed.as_secs();
621 let hours = total_secs / 3600;
622 let minutes = (total_secs % 3600) / 60;
623 let seconds = total_secs % 60;
624 if hours > 0 {
625 println!("\n Total Elapsed Time: {}h {}m {}s", hours, minutes, seconds);
626 } else if minutes > 0 {
627 println!("\n Total Elapsed Time: {}m {}s", minutes, seconds);
628 } else {
629 println!("\n Total Elapsed Time: {}s", seconds);
630 }
631
632 if self.results_format == "aggregated" || self.results_format == "both" {
634 let summary_path = self.output.join("aggregated_summary.json");
635 let summary_json = serde_json::json!({
636 "total_elapsed_seconds": elapsed.as_secs(),
637 "total_targets": results.total_targets,
638 "successful_targets": results.successful_targets,
639 "failed_targets": results.failed_targets,
640 "aggregated_metrics": {
641 "total_requests": results.aggregated_metrics.total_requests,
642 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
643 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
644 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
645 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
646 "error_rate": results.aggregated_metrics.error_rate,
647 "total_rps": results.aggregated_metrics.total_rps,
648 "avg_rps": results.aggregated_metrics.avg_rps,
649 "total_vus_max": results.aggregated_metrics.total_vus_max,
650 },
651 "target_results": results.target_results.iter().map(|r| {
652 serde_json::json!({
653 "target_url": r.target_url,
654 "target_index": r.target_index,
655 "success": r.success,
656 "error": r.error,
657 "total_requests": r.results.total_requests,
658 "failed_requests": r.results.failed_requests,
659 "avg_duration_ms": r.results.avg_duration_ms,
660 "min_duration_ms": r.results.min_duration_ms,
661 "med_duration_ms": r.results.med_duration_ms,
662 "p90_duration_ms": r.results.p90_duration_ms,
663 "p95_duration_ms": r.results.p95_duration_ms,
664 "p99_duration_ms": r.results.p99_duration_ms,
665 "max_duration_ms": r.results.max_duration_ms,
666 "rps": r.results.rps,
667 "vus_max": r.results.vus_max,
668 "output_dir": r.output_dir.to_string_lossy(),
669 })
670 }).collect::<Vec<_>>(),
671 });
672
673 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
674 TerminalReporter::print_success(&format!(
675 "Aggregated summary saved to: {}",
676 summary_path.display()
677 ));
678 }
679
680 let csv_path = self.output.join("all_targets.csv");
682 let mut csv = String::from(
683 "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
684 );
685 for r in &results.target_results {
686 csv.push_str(&format!(
687 "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
688 r.target_url,
689 r.success,
690 r.results.total_requests,
691 r.results.failed_requests,
692 r.results.rps,
693 r.results.vus_max,
694 r.results.min_duration_ms,
695 r.results.avg_duration_ms,
696 r.results.med_duration_ms,
697 r.results.p90_duration_ms,
698 r.results.p95_duration_ms,
699 r.results.p99_duration_ms,
700 r.results.max_duration_ms,
701 r.error.as_deref().unwrap_or(""),
702 ));
703 }
704 let _ = std::fs::write(&csv_path, &csv);
705
706 println!("\nResults saved to: {}", self.output.display());
707 println!(" - Per-target results: {}", self.output.join("target_*").display());
708 println!(" - All targets CSV: {}", csv_path.display());
709 if self.results_format == "aggregated" || self.results_format == "both" {
710 println!(
711 " - Aggregated summary: {}",
712 self.output.join("aggregated_summary.json").display()
713 );
714 }
715
716 Ok(())
717 }
718
719 pub fn parse_duration(duration: &str) -> Result<u64> {
721 let duration = duration.trim();
722
723 if let Some(secs) = duration.strip_suffix('s') {
724 secs.parse::<u64>()
725 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
726 } else if let Some(mins) = duration.strip_suffix('m') {
727 mins.parse::<u64>()
728 .map(|m| m * 60)
729 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
730 } else if let Some(hours) = duration.strip_suffix('h') {
731 hours
732 .parse::<u64>()
733 .map(|h| h * 3600)
734 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
735 } else {
736 duration
738 .parse::<u64>()
739 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
740 }
741 }
742
743 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
745 match &self.headers {
746 Some(s) => parse_header_string(s),
747 None => Ok(HashMap::new()),
748 }
749 }
750
751 fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
752 let extracted_path = output_dir.join("extracted_values.json");
753 if !extracted_path.exists() {
754 return Ok(ExtractedValues::new());
755 }
756
757 let content = std::fs::read_to_string(&extracted_path)
758 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
759 let parsed: serde_json::Value = serde_json::from_str(&content)
760 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
761
762 let mut extracted = ExtractedValues::new();
763 if let Some(values) = parsed.as_object() {
764 for (key, value) in values {
765 extracted.set(key.clone(), value.clone());
766 }
767 }
768
769 Ok(extracted)
770 }
771
772 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
781 if let Some(cli_base_path) = &self.base_path {
783 if cli_base_path.is_empty() {
784 return None;
786 }
787 return Some(cli_base_path.clone());
788 }
789
790 parser.get_base_path()
792 }
793
794 async fn build_mock_config(&self) -> MockIntegrationConfig {
796 if MockServerDetector::looks_like_mock_server(&self.target) {
798 if let Ok(info) = MockServerDetector::detect(&self.target).await {
800 if info.is_mockforge {
801 TerminalReporter::print_success(&format!(
802 "Detected MockForge server (version: {})",
803 info.version.as_deref().unwrap_or("unknown")
804 ));
805 return MockIntegrationConfig::mock_server();
806 }
807 }
808 }
809 MockIntegrationConfig::real_api()
810 }
811
812 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
814 if !self.crud_flow {
815 return None;
816 }
817
818 if let Some(config_path) = &self.flow_config {
820 match CrudFlowConfig::from_file(config_path) {
821 Ok(config) => return Some(config),
822 Err(e) => {
823 TerminalReporter::print_warning(&format!(
824 "Failed to load flow config: {}. Using auto-detection.",
825 e
826 ));
827 }
828 }
829 }
830
831 let extract_fields = self
833 .extract_fields
834 .as_ref()
835 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
836 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
837
838 Some(CrudFlowConfig {
839 flows: Vec::new(), default_extract_fields: extract_fields,
841 })
842 }
843
844 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
846 let data_file = self.data_file.as_ref()?;
847
848 let distribution = DataDistribution::from_str(&self.data_distribution)
849 .unwrap_or(DataDistribution::UniquePerVu);
850
851 let mappings = self
852 .data_mappings
853 .as_ref()
854 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
855 .unwrap_or_default();
856
857 Some(DataDrivenConfig {
858 file_path: data_file.to_string_lossy().to_string(),
859 distribution,
860 mappings,
861 csv_has_header: true,
862 per_uri_control: self.per_uri_control,
863 per_uri_columns: crate::data_driven::PerUriColumns::default(),
864 })
865 }
866
867 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
869 let error_rate = self.error_rate?;
870
871 let error_types = self
872 .error_types
873 .as_ref()
874 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
875 .unwrap_or_default();
876
877 Some(InvalidDataConfig {
878 error_rate,
879 error_types,
880 target_fields: Vec::new(),
881 })
882 }
883
884 fn build_security_config(&self) -> Option<SecurityTestConfig> {
886 if !self.security_test {
887 return None;
888 }
889
890 let categories = self
891 .security_categories
892 .as_ref()
893 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
894 .unwrap_or_else(|| {
895 let mut default = HashSet::new();
896 default.insert(SecurityCategory::SqlInjection);
897 default.insert(SecurityCategory::Xss);
898 default
899 });
900
901 let target_fields = self
902 .security_target_fields
903 .as_ref()
904 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
905 .unwrap_or_default();
906
907 let custom_payloads_file =
908 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
909
910 Some(SecurityTestConfig {
911 enabled: true,
912 categories,
913 target_fields,
914 custom_payloads_file,
915 include_high_risk: false,
916 })
917 }
918
919 fn build_parallel_config(&self) -> Option<ParallelConfig> {
921 let count = self.parallel_create?;
922
923 Some(ParallelConfig::new(count))
924 }
925
926 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
928 let Some(ref wafbench_dir) = self.wafbench_dir else {
929 return Vec::new();
930 };
931
932 let mut loader = WafBenchLoader::new();
933
934 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
935 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
936 return Vec::new();
937 }
938
939 let stats = loader.stats();
940
941 if stats.files_processed == 0 {
942 TerminalReporter::print_warning(&format!(
943 "No WAFBench YAML files found matching '{}'",
944 wafbench_dir
945 ));
946 if !stats.parse_errors.is_empty() {
948 TerminalReporter::print_warning("Some files were found but failed to parse:");
949 for error in &stats.parse_errors {
950 TerminalReporter::print_warning(&format!(" - {}", error));
951 }
952 }
953 return Vec::new();
954 }
955
956 TerminalReporter::print_progress(&format!(
957 "Loaded {} WAFBench files, {} test cases, {} payloads",
958 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
959 ));
960
961 for (category, count) in &stats.by_category {
963 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
964 }
965
966 for error in &stats.parse_errors {
968 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
969 }
970
971 loader.to_security_payloads()
972 }
973
974 pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
976 let mut enhanced_script = base_script.to_string();
977 let mut additional_code = String::new();
978
979 if let Some(config) = self.build_data_driven_config() {
981 TerminalReporter::print_progress("Adding data-driven testing support...");
982 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
983 additional_code.push('\n');
984 TerminalReporter::print_success("Data-driven testing enabled");
985 }
986
987 if let Some(config) = self.build_invalid_data_config() {
989 TerminalReporter::print_progress("Adding invalid data testing support...");
990 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
991 additional_code.push('\n');
992 additional_code
993 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
994 additional_code.push('\n');
995 additional_code
996 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
997 additional_code.push('\n');
998 TerminalReporter::print_success(&format!(
999 "Invalid data testing enabled ({}% error rate)",
1000 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
1001 ));
1002 }
1003
1004 let security_config = self.build_security_config();
1006 let wafbench_payloads = self.load_wafbench_payloads();
1007 let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
1008
1009 if security_config.is_some() || !wafbench_payloads.is_empty() {
1010 TerminalReporter::print_progress("Adding security testing support...");
1011
1012 let mut payload_list: Vec<SecurityPayload> = Vec::new();
1014
1015 if let Some(ref config) = security_config {
1016 payload_list.extend(SecurityPayloads::get_payloads(config));
1017 }
1018
1019 if !wafbench_payloads.is_empty() {
1021 TerminalReporter::print_progress(&format!(
1022 "Loading {} WAFBench attack patterns...",
1023 wafbench_payloads.len()
1024 ));
1025 payload_list.extend(wafbench_payloads);
1026 }
1027
1028 let target_fields =
1029 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
1030
1031 additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
1032 &payload_list,
1033 self.wafbench_cycle_all,
1034 ));
1035 additional_code.push('\n');
1036 additional_code
1037 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
1038 additional_code.push('\n');
1039 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
1040 additional_code.push('\n');
1041
1042 let mode = if self.wafbench_cycle_all {
1043 "cycle-all"
1044 } else {
1045 "random"
1046 };
1047 TerminalReporter::print_success(&format!(
1048 "Security testing enabled ({} payloads, {} mode)",
1049 payload_list.len(),
1050 mode
1051 ));
1052 } else if security_requested {
1053 TerminalReporter::print_warning(
1057 "Security testing was requested but no payloads were loaded. \
1058 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
1059 );
1060 additional_code
1061 .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
1062 additional_code.push('\n');
1063 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1064 additional_code.push('\n');
1065 }
1066
1067 if let Some(config) = self.build_parallel_config() {
1069 TerminalReporter::print_progress("Adding parallel execution support...");
1070 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1071 additional_code.push('\n');
1072 TerminalReporter::print_success(&format!(
1073 "Parallel execution enabled (count: {})",
1074 config.count
1075 ));
1076 }
1077
1078 if !additional_code.is_empty() {
1080 if let Some(import_end) = enhanced_script.find("export const options") {
1082 enhanced_script.insert_str(
1083 import_end,
1084 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1085 );
1086 }
1087 }
1088
1089 Ok(enhanced_script)
1090 }
1091
1092 async fn execute_sequential_specs(&self) -> Result<()> {
1094 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1095
1096 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1098
1099 if !self.spec.is_empty() {
1100 let specs = load_specs_from_files(self.spec.clone())
1101 .await
1102 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1103 all_specs.extend(specs);
1104 }
1105
1106 if let Some(spec_dir) = &self.spec_dir {
1107 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1108 BenchError::Other(format!("Failed to load specs from directory: {}", e))
1109 })?;
1110 all_specs.extend(dir_specs);
1111 }
1112
1113 if all_specs.is_empty() {
1114 return Err(BenchError::Other(
1115 "No spec files found for sequential execution".to_string(),
1116 ));
1117 }
1118
1119 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1120
1121 let execution_order = if let Some(config_path) = &self.dependency_config {
1123 TerminalReporter::print_progress("Loading dependency configuration...");
1124 let config = SpecDependencyConfig::from_file(config_path)?;
1125
1126 if !config.disable_auto_detect && config.execution_order.is_empty() {
1127 self.detect_and_sort_specs(&all_specs)?
1129 } else {
1130 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1132 }
1133 } else {
1134 self.detect_and_sort_specs(&all_specs)?
1136 };
1137
1138 TerminalReporter::print_success(&format!(
1139 "Execution order: {}",
1140 execution_order
1141 .iter()
1142 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1143 .collect::<Vec<_>>()
1144 .join(" → ")
1145 ));
1146
1147 let mut extracted_values = ExtractedValues::new();
1149 let total_specs = execution_order.len();
1150
1151 for (index, spec_path) in execution_order.iter().enumerate() {
1152 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1153
1154 TerminalReporter::print_progress(&format!(
1155 "[{}/{}] Executing spec: {}",
1156 index + 1,
1157 total_specs,
1158 spec_name
1159 ));
1160
1161 let spec = all_specs
1163 .iter()
1164 .find(|(p, _)| {
1165 p == spec_path
1166 || p.file_name() == spec_path.file_name()
1167 || p.file_name() == Some(spec_path.as_os_str())
1168 })
1169 .map(|(_, s)| s.clone())
1170 .ok_or_else(|| {
1171 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1172 })?;
1173
1174 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1176
1177 extracted_values.merge(&new_values);
1179
1180 TerminalReporter::print_success(&format!(
1181 "[{}/{}] Completed: {} (extracted {} values)",
1182 index + 1,
1183 total_specs,
1184 spec_name,
1185 new_values.values.len()
1186 ));
1187 }
1188
1189 TerminalReporter::print_success(&format!(
1190 "Sequential execution complete: {} specs executed",
1191 total_specs
1192 ));
1193
1194 Ok(())
1195 }
1196
1197 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1199 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1200
1201 let mut detector = DependencyDetector::new();
1202 let dependencies = detector.detect_dependencies(specs);
1203
1204 if dependencies.is_empty() {
1205 TerminalReporter::print_progress("No dependencies detected, using file order");
1206 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1207 }
1208
1209 TerminalReporter::print_progress(&format!(
1210 "Detected {} cross-spec dependencies",
1211 dependencies.len()
1212 ));
1213
1214 for dep in &dependencies {
1215 TerminalReporter::print_progress(&format!(
1216 " {} → {} (via field '{}')",
1217 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1218 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1219 dep.field_name
1220 ));
1221 }
1222
1223 topological_sort(specs, &dependencies)
1224 }
1225
1226 async fn execute_single_spec(
1228 &self,
1229 spec: &OpenApiSpec,
1230 spec_name: &str,
1231 _external_values: &ExtractedValues,
1232 ) -> Result<ExtractedValues> {
1233 let parser = SpecParser::from_spec(spec.clone());
1234
1235 if self.crud_flow {
1237 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1239 } else {
1240 self.execute_standard_spec(&parser, spec_name).await?;
1242 Ok(ExtractedValues::new())
1243 }
1244 }
1245
1246 async fn execute_crud_flow_with_extraction(
1248 &self,
1249 parser: &SpecParser,
1250 spec_name: &str,
1251 ) -> Result<ExtractedValues> {
1252 let operations = parser.get_operations();
1253 let flows = CrudFlowDetector::detect_flows(&operations);
1254
1255 if flows.is_empty() {
1256 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1257 return Ok(ExtractedValues::new());
1258 }
1259
1260 TerminalReporter::print_progress(&format!(
1261 " {} CRUD flow(s) in {}",
1262 flows.len(),
1263 spec_name
1264 ));
1265
1266 let mut handlebars = handlebars::Handlebars::new();
1268 handlebars.register_helper(
1270 "json",
1271 Box::new(
1272 |h: &handlebars::Helper,
1273 _: &handlebars::Handlebars,
1274 _: &handlebars::Context,
1275 _: &mut handlebars::RenderContext,
1276 out: &mut dyn handlebars::Output|
1277 -> handlebars::HelperResult {
1278 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1279 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1280 Ok(())
1281 },
1282 ),
1283 );
1284 let template = include_str!("templates/k6_crud_flow.hbs");
1285 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1286
1287 let custom_headers = self.parse_headers()?;
1288 let config = self.build_crud_flow_config().unwrap_or_default();
1289
1290 let param_overrides = if let Some(params_file) = &self.params_file {
1292 let overrides = ParameterOverrides::from_file(params_file)?;
1293 Some(overrides)
1294 } else {
1295 None
1296 };
1297
1298 let duration_secs = Self::parse_duration(&self.duration)?;
1300 let scenario =
1301 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1302 let stages = scenario.generate_stages(duration_secs, self.vus);
1303
1304 let api_base_path = self.resolve_base_path(parser);
1306
1307 let mut all_headers = custom_headers.clone();
1309 if let Some(auth) = &self.auth {
1310 all_headers.insert("Authorization".to_string(), auth.clone());
1311 }
1312 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1313
1314 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1316
1317 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1318 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1319 serde_json::json!({
1320 "name": sanitized_name.clone(),
1321 "display_name": f.name,
1322 "base_path": f.base_path,
1323 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1324 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1326 let method_raw = if !parts.is_empty() {
1327 parts[0].to_uppercase()
1328 } else {
1329 "GET".to_string()
1330 };
1331 let method = if !parts.is_empty() {
1332 let m = parts[0].to_lowercase();
1333 if m == "delete" { "del".to_string() } else { m }
1335 } else {
1336 "get".to_string()
1337 };
1338 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1339 let path = if let Some(ref bp) = api_base_path {
1341 format!("{}{}", bp, raw_path)
1342 } else {
1343 raw_path.to_string()
1344 };
1345 let is_get_or_head = method == "get" || method == "head";
1346 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1348
1349 let body_value = if has_body {
1351 param_overrides.as_ref()
1352 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1353 .and_then(|oo| oo.body)
1354 .unwrap_or_else(|| serde_json::json!({}))
1355 } else {
1356 serde_json::json!({})
1357 };
1358
1359 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1361
1362 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1364 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1365
1366 serde_json::json!({
1367 "operation": s.operation,
1368 "method": method,
1369 "path": path,
1370 "extract": s.extract,
1371 "use_values": s.use_values,
1372 "use_body": s.use_body,
1373 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1374 "inject_attacks": s.inject_attacks,
1375 "attack_types": s.attack_types,
1376 "description": s.description,
1377 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1378 "is_get_or_head": is_get_or_head,
1379 "has_body": has_body,
1380 "body": processed_body.value,
1381 "body_is_dynamic": body_is_dynamic,
1382 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1383 })
1384 }).collect::<Vec<_>>(),
1385 })
1386 }).collect();
1387
1388 for flow_data in &flows_data {
1390 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1391 for step in steps {
1392 if let Some(placeholders_arr) =
1393 step.get("_placeholders").and_then(|p| p.as_array())
1394 {
1395 for p_str in placeholders_arr {
1396 if let Some(p_name) = p_str.as_str() {
1397 match p_name {
1398 "VU" => {
1399 all_placeholders.insert(DynamicPlaceholder::VU);
1400 }
1401 "Iteration" => {
1402 all_placeholders.insert(DynamicPlaceholder::Iteration);
1403 }
1404 "Timestamp" => {
1405 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1406 }
1407 "UUID" => {
1408 all_placeholders.insert(DynamicPlaceholder::UUID);
1409 }
1410 "Random" => {
1411 all_placeholders.insert(DynamicPlaceholder::Random);
1412 }
1413 "Counter" => {
1414 all_placeholders.insert(DynamicPlaceholder::Counter);
1415 }
1416 "Date" => {
1417 all_placeholders.insert(DynamicPlaceholder::Date);
1418 }
1419 "VuIter" => {
1420 all_placeholders.insert(DynamicPlaceholder::VuIter);
1421 }
1422 _ => {}
1423 }
1424 }
1425 }
1426 }
1427 }
1428 }
1429 }
1430
1431 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1433 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1434
1435 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1437
1438 let data = serde_json::json!({
1439 "base_url": self.target,
1440 "flows": flows_data,
1441 "extract_fields": config.default_extract_fields,
1442 "duration_secs": duration_secs,
1443 "max_vus": self.vus,
1444 "auth_header": self.auth,
1445 "custom_headers": custom_headers,
1446 "skip_tls_verify": self.skip_tls_verify,
1447 "stages": stages.iter().map(|s| serde_json::json!({
1449 "duration": s.duration,
1450 "target": s.target,
1451 })).collect::<Vec<_>>(),
1452 "threshold_percentile": self.threshold_percentile,
1453 "threshold_ms": self.threshold_ms,
1454 "max_error_rate": self.max_error_rate,
1455 "headers": headers_json,
1456 "dynamic_imports": required_imports,
1457 "dynamic_globals": required_globals,
1458 "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
1459 "security_testing_enabled": security_testing_enabled,
1461 "has_custom_headers": !custom_headers.is_empty(),
1462 });
1463
1464 let mut script = handlebars
1465 .render_template(template, &data)
1466 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1467
1468 if security_testing_enabled {
1470 script = self.generate_enhanced_script(&script)?;
1471 }
1472
1473 let script_path =
1475 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1476
1477 std::fs::create_dir_all(self.output.clone())?;
1478 std::fs::write(&script_path, &script)?;
1479
1480 if !self.generate_only {
1481 let executor = K6Executor::new()?;
1482 std::fs::create_dir_all(&output_dir)?;
1483
1484 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1485
1486 let extracted = Self::parse_extracted_values(&output_dir)?;
1487 TerminalReporter::print_progress(&format!(
1488 " Extracted {} value(s) from {}",
1489 extracted.values.len(),
1490 spec_name
1491 ));
1492 return Ok(extracted);
1493 }
1494
1495 Ok(ExtractedValues::new())
1496 }
1497
1498 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1500 let mut operations = if let Some(filter) = &self.operations {
1501 parser.filter_operations(filter)?
1502 } else {
1503 parser.get_operations()
1504 };
1505
1506 if let Some(exclude) = &self.exclude_operations {
1507 operations = parser.exclude_operations(operations, exclude)?;
1508 }
1509
1510 if operations.is_empty() {
1511 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1512 return Ok(());
1513 }
1514
1515 TerminalReporter::print_progress(&format!(
1516 " {} operations in {}",
1517 operations.len(),
1518 spec_name
1519 ));
1520
1521 let templates: Vec<_> = operations
1523 .iter()
1524 .map(RequestGenerator::generate_template)
1525 .collect::<Result<Vec<_>>>()?;
1526
1527 let custom_headers = self.parse_headers()?;
1529
1530 let base_path = self.resolve_base_path(parser);
1532
1533 let scenario =
1535 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1536
1537 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
1538
1539 let k6_config = K6Config {
1540 target_url: self.target.clone(),
1541 base_path,
1542 scenario,
1543 duration_secs: Self::parse_duration(&self.duration)?,
1544 max_vus: self.vus,
1545 threshold_percentile: self.threshold_percentile.clone(),
1546 threshold_ms: self.threshold_ms,
1547 max_error_rate: self.max_error_rate,
1548 auth_header: self.auth.clone(),
1549 custom_headers,
1550 skip_tls_verify: self.skip_tls_verify,
1551 security_testing_enabled,
1552 };
1553
1554 let generator = K6ScriptGenerator::new(k6_config, templates);
1555 let mut script = generator.generate()?;
1556
1557 let has_advanced_features = self.data_file.is_some()
1559 || self.error_rate.is_some()
1560 || self.security_test
1561 || self.parallel_create.is_some()
1562 || self.wafbench_dir.is_some();
1563
1564 if has_advanced_features {
1565 script = self.generate_enhanced_script(&script)?;
1566 }
1567
1568 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1570
1571 std::fs::create_dir_all(self.output.clone())?;
1572 std::fs::write(&script_path, &script)?;
1573
1574 if !self.generate_only {
1575 let executor = K6Executor::new()?;
1576 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1577 std::fs::create_dir_all(&output_dir)?;
1578
1579 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1580 }
1581
1582 Ok(())
1583 }
1584
1585 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1587 let config = self.build_crud_flow_config().unwrap_or_default();
1589
1590 let flows = if !config.flows.is_empty() {
1592 TerminalReporter::print_progress("Using custom flow configuration...");
1593 config.flows.clone()
1594 } else {
1595 TerminalReporter::print_progress("Detecting CRUD operations...");
1596 let operations = parser.get_operations();
1597 CrudFlowDetector::detect_flows(&operations)
1598 };
1599
1600 if flows.is_empty() {
1601 return Err(BenchError::Other(
1602 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1603 ));
1604 }
1605
1606 if config.flows.is_empty() {
1607 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1608 } else {
1609 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1610 }
1611
1612 for flow in &flows {
1613 TerminalReporter::print_progress(&format!(
1614 " - {}: {} steps",
1615 flow.name,
1616 flow.steps.len()
1617 ));
1618 }
1619
1620 let mut handlebars = handlebars::Handlebars::new();
1622 handlebars.register_helper(
1624 "json",
1625 Box::new(
1626 |h: &handlebars::Helper,
1627 _: &handlebars::Handlebars,
1628 _: &handlebars::Context,
1629 _: &mut handlebars::RenderContext,
1630 out: &mut dyn handlebars::Output|
1631 -> handlebars::HelperResult {
1632 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1633 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1634 Ok(())
1635 },
1636 ),
1637 );
1638 let template = include_str!("templates/k6_crud_flow.hbs");
1639
1640 let custom_headers = self.parse_headers()?;
1641
1642 let param_overrides = if let Some(params_file) = &self.params_file {
1644 TerminalReporter::print_progress("Loading parameter overrides...");
1645 let overrides = ParameterOverrides::from_file(params_file)?;
1646 TerminalReporter::print_success(&format!(
1647 "Loaded parameter overrides ({} operation-specific, {} defaults)",
1648 overrides.operations.len(),
1649 if overrides.defaults.is_empty() { 0 } else { 1 }
1650 ));
1651 Some(overrides)
1652 } else {
1653 None
1654 };
1655
1656 let duration_secs = Self::parse_duration(&self.duration)?;
1658 let scenario =
1659 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1660 let stages = scenario.generate_stages(duration_secs, self.vus);
1661
1662 let api_base_path = self.resolve_base_path(parser);
1664 if let Some(ref bp) = api_base_path {
1665 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1666 }
1667
1668 let mut all_headers = custom_headers.clone();
1670 if let Some(auth) = &self.auth {
1671 all_headers.insert("Authorization".to_string(), auth.clone());
1672 }
1673 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1674
1675 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1677
1678 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1679 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1681 serde_json::json!({
1682 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1685 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1686 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1688 let method_raw = if !parts.is_empty() {
1689 parts[0].to_uppercase()
1690 } else {
1691 "GET".to_string()
1692 };
1693 let method = if !parts.is_empty() {
1694 let m = parts[0].to_lowercase();
1695 if m == "delete" { "del".to_string() } else { m }
1697 } else {
1698 "get".to_string()
1699 };
1700 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1701 let path = if let Some(ref bp) = api_base_path {
1703 format!("{}{}", bp, raw_path)
1704 } else {
1705 raw_path.to_string()
1706 };
1707 let is_get_or_head = method == "get" || method == "head";
1708 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1710
1711 let body_value = if has_body {
1713 param_overrides.as_ref()
1714 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1715 .and_then(|oo| oo.body)
1716 .unwrap_or_else(|| serde_json::json!({}))
1717 } else {
1718 serde_json::json!({})
1719 };
1720
1721 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1723 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1728 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1729
1730 serde_json::json!({
1731 "operation": s.operation,
1732 "method": method,
1733 "path": path,
1734 "extract": s.extract,
1735 "use_values": s.use_values,
1736 "use_body": s.use_body,
1737 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1738 "inject_attacks": s.inject_attacks,
1739 "attack_types": s.attack_types,
1740 "description": s.description,
1741 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1742 "is_get_or_head": is_get_or_head,
1743 "has_body": has_body,
1744 "body": processed_body.value,
1745 "body_is_dynamic": body_is_dynamic,
1746 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1747 })
1748 }).collect::<Vec<_>>(),
1749 })
1750 }).collect();
1751
1752 for flow_data in &flows_data {
1754 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1755 for step in steps {
1756 if let Some(placeholders_arr) =
1757 step.get("_placeholders").and_then(|p| p.as_array())
1758 {
1759 for p_str in placeholders_arr {
1760 if let Some(p_name) = p_str.as_str() {
1761 match p_name {
1763 "VU" => {
1764 all_placeholders.insert(DynamicPlaceholder::VU);
1765 }
1766 "Iteration" => {
1767 all_placeholders.insert(DynamicPlaceholder::Iteration);
1768 }
1769 "Timestamp" => {
1770 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1771 }
1772 "UUID" => {
1773 all_placeholders.insert(DynamicPlaceholder::UUID);
1774 }
1775 "Random" => {
1776 all_placeholders.insert(DynamicPlaceholder::Random);
1777 }
1778 "Counter" => {
1779 all_placeholders.insert(DynamicPlaceholder::Counter);
1780 }
1781 "Date" => {
1782 all_placeholders.insert(DynamicPlaceholder::Date);
1783 }
1784 "VuIter" => {
1785 all_placeholders.insert(DynamicPlaceholder::VuIter);
1786 }
1787 _ => {}
1788 }
1789 }
1790 }
1791 }
1792 }
1793 }
1794 }
1795
1796 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1798 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1799
1800 let invalid_data_config = self.build_invalid_data_config();
1802 let error_injection_enabled = invalid_data_config.is_some();
1803 let error_rate = self.error_rate.unwrap_or(0.0);
1804 let error_types: Vec<String> = invalid_data_config
1805 .as_ref()
1806 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1807 .unwrap_or_default();
1808
1809 if error_injection_enabled {
1810 TerminalReporter::print_progress(&format!(
1811 "Error injection enabled ({}% rate)",
1812 (error_rate * 100.0) as u32
1813 ));
1814 }
1815
1816 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1818
1819 let data = serde_json::json!({
1820 "base_url": self.target,
1821 "flows": flows_data,
1822 "extract_fields": config.default_extract_fields,
1823 "duration_secs": duration_secs,
1824 "max_vus": self.vus,
1825 "auth_header": self.auth,
1826 "custom_headers": custom_headers,
1827 "skip_tls_verify": self.skip_tls_verify,
1828 "stages": stages.iter().map(|s| serde_json::json!({
1830 "duration": s.duration,
1831 "target": s.target,
1832 })).collect::<Vec<_>>(),
1833 "threshold_percentile": self.threshold_percentile,
1834 "threshold_ms": self.threshold_ms,
1835 "max_error_rate": self.max_error_rate,
1836 "headers": headers_json,
1837 "dynamic_imports": required_imports,
1838 "dynamic_globals": required_globals,
1839 "extracted_values_output_path": self
1840 .output
1841 .join("crud_flow_extracted_values.json")
1842 .to_string_lossy(),
1843 "error_injection_enabled": error_injection_enabled,
1845 "error_rate": error_rate,
1846 "error_types": error_types,
1847 "security_testing_enabled": security_testing_enabled,
1849 "has_custom_headers": !custom_headers.is_empty(),
1850 });
1851
1852 let mut script = handlebars
1853 .render_template(template, &data)
1854 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1855
1856 if security_testing_enabled {
1858 script = self.generate_enhanced_script(&script)?;
1859 }
1860
1861 TerminalReporter::print_progress("Validating CRUD flow script...");
1863 let validation_errors = K6ScriptGenerator::validate_script(&script);
1864 if !validation_errors.is_empty() {
1865 TerminalReporter::print_error("CRUD flow script validation failed");
1866 for error in &validation_errors {
1867 eprintln!(" {}", error);
1868 }
1869 return Err(BenchError::Other(format!(
1870 "CRUD flow script validation failed with {} error(s)",
1871 validation_errors.len()
1872 )));
1873 }
1874
1875 TerminalReporter::print_success("CRUD flow script generated");
1876
1877 let script_path = if let Some(output) = &self.script_output {
1879 output.clone()
1880 } else {
1881 self.output.join("k6-crud-flow-script.js")
1882 };
1883
1884 if let Some(parent) = script_path.parent() {
1885 std::fs::create_dir_all(parent)?;
1886 }
1887 std::fs::write(&script_path, &script)?;
1888 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1889
1890 if self.generate_only {
1891 println!("\nScript generated successfully. Run it with:");
1892 println!(" k6 run {}", script_path.display());
1893 return Ok(());
1894 }
1895
1896 TerminalReporter::print_progress("Executing CRUD flow test...");
1898 let executor = K6Executor::new()?;
1899 std::fs::create_dir_all(&self.output)?;
1900
1901 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1902
1903 let duration_secs = Self::parse_duration(&self.duration)?;
1904 TerminalReporter::print_summary(&results, duration_secs);
1905
1906 Ok(())
1907 }
1908
1909 async fn execute_conformance_test(&self) -> Result<()> {
1911 use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
1912 use crate::conformance::report::ConformanceReport;
1913 use crate::conformance::spec::ConformanceFeature;
1914
1915 TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
1916
1917 TerminalReporter::print_progress(
1920 "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
1921 );
1922
1923 let categories = self.conformance_categories.as_ref().map(|cats_str| {
1925 cats_str
1926 .split(',')
1927 .filter_map(|s| {
1928 let trimmed = s.trim();
1929 if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
1930 Some(canonical.to_string())
1931 } else {
1932 TerminalReporter::print_warning(&format!(
1933 "Unknown conformance category: '{}'. Valid categories: {}",
1934 trimmed,
1935 ConformanceFeature::cli_category_names()
1936 .iter()
1937 .map(|(cli, _)| *cli)
1938 .collect::<Vec<_>>()
1939 .join(", ")
1940 ));
1941 None
1942 }
1943 })
1944 .collect::<Vec<String>>()
1945 });
1946
1947 let custom_headers: Vec<(String, String)> = self
1949 .conformance_headers
1950 .iter()
1951 .filter_map(|h| {
1952 let (name, value) = h.split_once(':')?;
1953 Some((name.trim().to_string(), value.trim().to_string()))
1954 })
1955 .collect();
1956
1957 if !custom_headers.is_empty() {
1958 TerminalReporter::print_progress(&format!(
1959 "Using {} custom header(s) for authentication",
1960 custom_headers.len()
1961 ));
1962 }
1963
1964 if self.conformance_delay_ms > 0 {
1965 TerminalReporter::print_progress(&format!(
1966 "Using {}ms delay between conformance requests",
1967 self.conformance_delay_ms
1968 ));
1969 }
1970
1971 std::fs::create_dir_all(&self.output)?;
1973
1974 let config = ConformanceConfig {
1975 target_url: self.target.clone(),
1976 api_key: self.conformance_api_key.clone(),
1977 basic_auth: self.conformance_basic_auth.clone(),
1978 skip_tls_verify: self.skip_tls_verify,
1979 categories,
1980 base_path: self.base_path.clone(),
1981 custom_headers,
1982 output_dir: Some(self.output.clone()),
1983 all_operations: self.conformance_all_operations,
1984 custom_checks_file: self.conformance_custom.clone(),
1985 request_delay_ms: self.conformance_delay_ms,
1986 custom_filter: self.conformance_custom_filter.clone(),
1987 export_requests: self.export_requests,
1988 };
1989
1990 let annotated_ops = if !self.spec.is_empty() {
1993 TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
1994 let parser = SpecParser::from_file(&self.spec[0]).await?;
1995 let operations = parser.get_operations();
1996
1997 let annotated =
1998 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
1999 &operations,
2000 parser.spec(),
2001 );
2002 TerminalReporter::print_success(&format!(
2003 "Analyzed {} operations, found {} feature annotations",
2004 operations.len(),
2005 annotated.iter().map(|a| a.features.len()).sum::<usize>()
2006 ));
2007 Some(annotated)
2008 } else {
2009 None
2010 };
2011
2012 if self.generate_only || self.use_k6 {
2014 let script = if let Some(annotated) = &annotated_ops {
2015 let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2016 config,
2017 annotated.clone(),
2018 );
2019 let op_count = gen.operation_count();
2020 let (script, check_count) = gen.generate()?;
2021 TerminalReporter::print_success(&format!(
2022 "Conformance: {} operations analyzed, {} unique checks generated",
2023 op_count, check_count
2024 ));
2025 script
2026 } else {
2027 let generator = ConformanceGenerator::new(config);
2028 generator.generate()?
2029 };
2030
2031 let script_path = self.output.join("k6-conformance.js");
2032 std::fs::write(&script_path, &script).map_err(|e| {
2033 BenchError::Other(format!("Failed to write conformance script: {}", e))
2034 })?;
2035 TerminalReporter::print_success(&format!(
2036 "Conformance script generated: {}",
2037 script_path.display()
2038 ));
2039
2040 if self.generate_only {
2041 println!("\nScript generated. Run with:");
2042 println!(" k6 run {}", script_path.display());
2043 return Ok(());
2044 }
2045
2046 if !K6Executor::is_k6_installed() {
2048 TerminalReporter::print_error("k6 is not installed");
2049 TerminalReporter::print_warning(
2050 "Install k6 from: https://k6.io/docs/get-started/installation/",
2051 );
2052 return Err(BenchError::K6NotFound);
2053 }
2054
2055 TerminalReporter::print_progress("Running conformance tests via k6...");
2056 let executor = K6Executor::new()?;
2057 executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2058
2059 let report_path = self.output.join("conformance-report.json");
2060 if report_path.exists() {
2061 let report = ConformanceReport::from_file(&report_path)?;
2062 report.print_report_with_options(self.conformance_all_operations);
2063 self.save_conformance_report(&report, &report_path)?;
2064 } else {
2065 TerminalReporter::print_warning(
2066 "Conformance report not generated (k6 handleSummary may not have run)",
2067 );
2068 }
2069
2070 return Ok(());
2071 }
2072
2073 TerminalReporter::print_progress("Running conformance tests (native executor)...");
2075
2076 let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2077
2078 executor = if let Some(annotated) = &annotated_ops {
2079 executor.with_spec_driven_checks(annotated)
2080 } else {
2081 executor.with_reference_checks()
2082 };
2083 executor = executor.with_custom_checks()?;
2084
2085 TerminalReporter::print_success(&format!(
2086 "Executing {} conformance checks...",
2087 executor.check_count()
2088 ));
2089
2090 let report = executor.execute().await?;
2091 report.print_report_with_options(self.conformance_all_operations);
2092
2093 let failure_details = report.failure_details();
2095 if !failure_details.is_empty() {
2096 let details_path = self.output.join("conformance-failure-details.json");
2097 if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2098 let _ = std::fs::write(&details_path, json);
2099 TerminalReporter::print_success(&format!(
2100 "Failure details saved to: {}",
2101 details_path.display()
2102 ));
2103 }
2104 }
2105
2106 let report_path = self.output.join("conformance-report.json");
2108 let report_json = serde_json::to_string_pretty(&report.to_json())
2109 .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2110 std::fs::write(&report_path, &report_json)
2111 .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2112 TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
2113
2114 self.save_conformance_report(&report, &report_path)?;
2115
2116 Ok(())
2117 }
2118
2119 fn save_conformance_report(
2121 &self,
2122 report: &crate::conformance::report::ConformanceReport,
2123 report_path: &Path,
2124 ) -> Result<()> {
2125 if self.conformance_report_format == "sarif" {
2126 use crate::conformance::sarif::ConformanceSarifReport;
2127 ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
2128 TerminalReporter::print_success(&format!(
2129 "SARIF report saved to: {}",
2130 self.conformance_report.display()
2131 ));
2132 } else if self.conformance_report != *report_path {
2133 std::fs::copy(report_path, &self.conformance_report)?;
2134 TerminalReporter::print_success(&format!(
2135 "Report saved to: {}",
2136 self.conformance_report.display()
2137 ));
2138 }
2139 Ok(())
2140 }
2141
2142 async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
2148 use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2149 use crate::conformance::report::ConformanceReport;
2150 use crate::conformance::spec::ConformanceFeature;
2151
2152 TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
2153
2154 TerminalReporter::print_progress("Parsing targets file...");
2156 let targets = parse_targets_file(targets_file)?;
2157 let num_targets = targets.len();
2158 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
2159
2160 if targets.is_empty() {
2161 return Err(BenchError::Other("No targets found in file".to_string()));
2162 }
2163
2164 TerminalReporter::print_progress(
2165 "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
2166 );
2167
2168 let categories = self.conformance_categories.as_ref().map(|cats_str| {
2170 cats_str
2171 .split(',')
2172 .filter_map(|s| {
2173 let trimmed = s.trim();
2174 if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2175 Some(canonical.to_string())
2176 } else {
2177 TerminalReporter::print_warning(&format!(
2178 "Unknown conformance category: '{}'. Valid categories: {}",
2179 trimmed,
2180 ConformanceFeature::cli_category_names()
2181 .iter()
2182 .map(|(cli, _)| *cli)
2183 .collect::<Vec<_>>()
2184 .join(", ")
2185 ));
2186 None
2187 }
2188 })
2189 .collect::<Vec<String>>()
2190 });
2191
2192 let base_custom_headers: Vec<(String, String)> = self
2194 .conformance_headers
2195 .iter()
2196 .filter_map(|h| {
2197 let (name, value) = h.split_once(':')?;
2198 Some((name.trim().to_string(), value.trim().to_string()))
2199 })
2200 .collect();
2201
2202 if !base_custom_headers.is_empty() {
2203 TerminalReporter::print_progress(&format!(
2204 "Using {} base custom header(s) for authentication",
2205 base_custom_headers.len()
2206 ));
2207 }
2208
2209 let annotated_ops = if !self.spec.is_empty() {
2211 TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2212 let parser = SpecParser::from_file(&self.spec[0]).await?;
2213 let operations = parser.get_operations();
2214 let annotated =
2215 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2216 &operations,
2217 parser.spec(),
2218 );
2219 TerminalReporter::print_success(&format!(
2220 "Analyzed {} operations, found {} feature annotations",
2221 operations.len(),
2222 annotated.iter().map(|a| a.features.len()).sum::<usize>()
2223 ));
2224 Some(annotated)
2225 } else {
2226 None
2227 };
2228
2229 std::fs::create_dir_all(&self.output)?;
2231
2232 struct TargetResult {
2234 url: String,
2235 passed: usize,
2236 failed: usize,
2237 elapsed: std::time::Duration,
2238 report_json: serde_json::Value,
2239 owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
2240 }
2241
2242 let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
2243 let total_start = std::time::Instant::now();
2244
2245 for (idx, target) in targets.iter().enumerate() {
2246 tracing::info!(
2247 "Running conformance tests against target {}/{}: {}",
2248 idx + 1,
2249 num_targets,
2250 target.url
2251 );
2252 TerminalReporter::print_progress(&format!(
2253 "\n--- Target {}/{}: {} ---",
2254 idx + 1,
2255 num_targets,
2256 target.url
2257 ));
2258
2259 let mut merged_headers = base_custom_headers.clone();
2261 if let Some(ref target_headers) = target.headers {
2262 for (name, value) in target_headers {
2263 if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
2265 existing.1 = value.clone();
2266 } else {
2267 merged_headers.push((name.clone(), value.clone()));
2268 }
2269 }
2270 }
2271 if let Some(ref auth) = target.auth {
2273 if let Some(existing) =
2274 merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
2275 {
2276 existing.1 = auth.clone();
2277 } else {
2278 merged_headers.push(("Authorization".to_string(), auth.clone()));
2279 }
2280 }
2281
2282 let target_dir = self.output.join(format!("target_{}", idx));
2288 std::fs::create_dir_all(&target_dir)?;
2289
2290 let config = ConformanceConfig {
2291 target_url: target.url.clone(),
2292 api_key: self.conformance_api_key.clone(),
2293 basic_auth: self.conformance_basic_auth.clone(),
2294 skip_tls_verify: self.skip_tls_verify,
2295 categories: categories.clone(),
2296 base_path: self.base_path.clone(),
2297 custom_headers: merged_headers,
2298 output_dir: Some(target_dir.clone()),
2299 all_operations: self.conformance_all_operations,
2300 custom_checks_file: self.conformance_custom.clone(),
2301 request_delay_ms: self.conformance_delay_ms,
2302 custom_filter: self.conformance_custom_filter.clone(),
2303 export_requests: self.export_requests,
2304 };
2305
2306 let target_start = std::time::Instant::now();
2307 let report = if self.use_k6 {
2308 if !K6Executor::is_k6_installed() {
2309 TerminalReporter::print_error("k6 is not installed");
2310 TerminalReporter::print_warning(
2311 "Install k6 from: https://k6.io/docs/get-started/installation/",
2312 );
2313 return Err(BenchError::K6NotFound);
2314 }
2315
2316 let script = if let Some(ref annotated) = annotated_ops {
2317 let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
2318 config.clone(),
2319 annotated.clone(),
2320 );
2321 let (script, _check_count) = gen.generate()?;
2322 script
2323 } else {
2324 let generator = ConformanceGenerator::new(config.clone());
2325 generator.generate()?
2326 };
2327
2328 let script_path = target_dir.join("k6-conformance.js");
2329 std::fs::write(&script_path, &script).map_err(|e| {
2330 BenchError::Other(format!("Failed to write conformance script: {}", e))
2331 })?;
2332 TerminalReporter::print_success(&format!(
2333 "Conformance script generated: {}",
2334 script_path.display()
2335 ));
2336
2337 TerminalReporter::print_progress(&format!(
2338 "Running conformance tests via k6 against {}...",
2339 target.url
2340 ));
2341 let k6 = K6Executor::new()?;
2342 let api_port = 6565u16.saturating_add(idx as u16);
2344 k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
2345 .await?;
2346
2347 let report_path = target_dir.join("conformance-report.json");
2348 if report_path.exists() {
2349 ConformanceReport::from_file(&report_path)?
2350 } else {
2351 TerminalReporter::print_warning(&format!(
2352 "Conformance report not generated for target {} (k6 handleSummary may not have run)",
2353 target.url
2354 ));
2355 continue;
2356 }
2357 } else {
2358 let mut executor =
2359 crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2360
2361 executor = if let Some(ref annotated) = annotated_ops {
2362 executor.with_spec_driven_checks(annotated)
2363 } else {
2364 executor.with_reference_checks()
2365 };
2366 executor = executor.with_custom_checks()?;
2367
2368 TerminalReporter::print_success(&format!(
2369 "Executing {} conformance checks against {}...",
2370 executor.check_count(),
2371 target.url
2372 ));
2373
2374 executor.execute().await?
2375 };
2376 let target_elapsed = target_start.elapsed();
2377
2378 let report_json = report.to_json();
2379
2380 let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
2382 let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
2383 let total_checks = passed + failed;
2384 let rate = if total_checks == 0 {
2385 0.0
2386 } else {
2387 (passed as f64 / total_checks as f64) * 100.0
2388 };
2389
2390 TerminalReporter::print_success(&format!(
2391 "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
2392 target.url,
2393 passed,
2394 total_checks,
2395 rate,
2396 target_elapsed.as_secs_f64()
2397 ));
2398
2399 let target_report_path = target_dir.join("conformance-report.json");
2401 let report_str = serde_json::to_string_pretty(&report_json)
2402 .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2403 std::fs::write(&target_report_path, &report_str)
2404 .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2405
2406 let failure_details = report.failure_details();
2408 if !failure_details.is_empty() {
2409 let details_path = target_dir.join("conformance-failure-details.json");
2410 if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2411 let _ = std::fs::write(&details_path, json);
2412 }
2413 }
2414
2415 let owasp_coverage = report.owasp_coverage_data();
2417
2418 target_results.push(TargetResult {
2419 url: target.url.clone(),
2420 passed,
2421 failed,
2422 elapsed: target_elapsed,
2423 report_json,
2424 owasp_coverage,
2425 });
2426 }
2427
2428 let total_elapsed = total_start.elapsed();
2429
2430 println!("\n{}", "=".repeat(80));
2432 println!(" Multi-Target Conformance Summary");
2433 println!("{}", "=".repeat(80));
2434 println!(
2435 " {:<40} {:>8} {:>8} {:>8} {:>8}",
2436 "Target URL", "Passed", "Failed", "Rate", "Time"
2437 );
2438 println!(" {}", "-".repeat(76));
2439
2440 let mut total_passed = 0usize;
2441 let mut total_failed = 0usize;
2442
2443 for result in &target_results {
2444 let total_checks = result.passed + result.failed;
2445 let rate = if total_checks == 0 {
2446 0.0
2447 } else {
2448 (result.passed as f64 / total_checks as f64) * 100.0
2449 };
2450
2451 let display_url = if result.url.len() > 38 {
2453 format!("{}...", &result.url[..35])
2454 } else {
2455 result.url.clone()
2456 };
2457
2458 println!(
2459 " {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
2460 display_url,
2461 result.passed,
2462 result.failed,
2463 rate,
2464 result.elapsed.as_secs_f64()
2465 );
2466
2467 total_passed += result.passed;
2468 total_failed += result.failed;
2469 }
2470
2471 let grand_total = total_passed + total_failed;
2472 let overall_rate = if grand_total == 0 {
2473 0.0
2474 } else {
2475 (total_passed as f64 / grand_total as f64) * 100.0
2476 };
2477
2478 println!(" {}", "-".repeat(76));
2479 println!(
2480 " {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
2481 format!("TOTAL ({} targets)", num_targets),
2482 total_passed,
2483 total_failed,
2484 overall_rate,
2485 total_elapsed.as_secs_f64()
2486 );
2487 println!("{}", "=".repeat(80));
2488
2489 for result in &target_results {
2491 println!("\n OWASP API Security Top 10 Coverage for {}:", result.url);
2492 for entry in &result.owasp_coverage {
2493 let status = if !entry.tested {
2494 "-"
2495 } else if entry.all_passed {
2496 "pass"
2497 } else {
2498 "FAIL"
2499 };
2500 let via = if entry.via_categories.is_empty() {
2501 String::new()
2502 } else {
2503 format!(" (via {})", entry.via_categories.join(", "))
2504 };
2505 println!(" {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
2506 }
2507 }
2508
2509 let per_target_summaries: Vec<serde_json::Value> = target_results
2511 .iter()
2512 .enumerate()
2513 .map(|(idx, r)| {
2514 let total_checks = r.passed + r.failed;
2515 let rate = if total_checks == 0 {
2516 0.0
2517 } else {
2518 (r.passed as f64 / total_checks as f64) * 100.0
2519 };
2520 let owasp_json: Vec<serde_json::Value> = r
2521 .owasp_coverage
2522 .iter()
2523 .map(|e| {
2524 serde_json::json!({
2525 "id": e.id,
2526 "name": e.name,
2527 "tested": e.tested,
2528 "all_passed": e.all_passed,
2529 "via_categories": e.via_categories,
2530 })
2531 })
2532 .collect();
2533 serde_json::json!({
2534 "target_url": r.url,
2535 "target_index": idx,
2536 "checks_passed": r.passed,
2537 "checks_failed": r.failed,
2538 "total_checks": total_checks,
2539 "pass_rate": rate,
2540 "elapsed_seconds": r.elapsed.as_secs_f64(),
2541 "report": r.report_json,
2542 "owasp_coverage": owasp_json,
2543 })
2544 })
2545 .collect();
2546
2547 let combined_summary = serde_json::json!({
2548 "total_targets": num_targets,
2549 "total_checks_passed": total_passed,
2550 "total_checks_failed": total_failed,
2551 "overall_pass_rate": overall_rate,
2552 "total_elapsed_seconds": total_elapsed.as_secs_f64(),
2553 "targets": per_target_summaries,
2554 });
2555
2556 let summary_path = self.output.join("multi-target-conformance-summary.json");
2557 let summary_str = serde_json::to_string_pretty(&combined_summary)
2558 .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
2559 std::fs::write(&summary_path, &summary_str)
2560 .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
2561 TerminalReporter::print_success(&format!(
2562 "Combined summary saved to: {}",
2563 summary_path.display()
2564 ));
2565
2566 Ok(())
2567 }
2568
2569 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
2571 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
2572
2573 let custom_headers = self.parse_headers()?;
2575
2576 let mut config = OwaspApiConfig::new()
2578 .with_auth_header(&self.owasp_auth_header)
2579 .with_verbose(self.verbose)
2580 .with_insecure(self.skip_tls_verify)
2581 .with_concurrency(self.vus as usize)
2582 .with_iterations(self.owasp_iterations as usize)
2583 .with_base_path(self.base_path.clone())
2584 .with_custom_headers(custom_headers);
2585
2586 if let Some(ref token) = self.owasp_auth_token {
2588 config = config.with_valid_auth_token(token);
2589 }
2590
2591 if let Some(ref cats_str) = self.owasp_categories {
2593 let categories: Vec<OwaspCategory> = cats_str
2594 .split(',')
2595 .filter_map(|s| {
2596 let trimmed = s.trim();
2597 match trimmed.parse::<OwaspCategory>() {
2598 Ok(cat) => Some(cat),
2599 Err(e) => {
2600 TerminalReporter::print_warning(&e);
2601 None
2602 }
2603 }
2604 })
2605 .collect();
2606
2607 if !categories.is_empty() {
2608 config = config.with_categories(categories);
2609 }
2610 }
2611
2612 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
2614 config.admin_paths_file = Some(admin_paths_file.clone());
2615 if let Err(e) = config.load_admin_paths() {
2616 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
2617 }
2618 }
2619
2620 if let Some(ref id_fields_str) = self.owasp_id_fields {
2622 let id_fields: Vec<String> = id_fields_str
2623 .split(',')
2624 .map(|s| s.trim().to_string())
2625 .filter(|s| !s.is_empty())
2626 .collect();
2627 if !id_fields.is_empty() {
2628 config = config.with_id_fields(id_fields);
2629 }
2630 }
2631
2632 if let Some(ref report_path) = self.owasp_report {
2634 config = config.with_report_path(report_path);
2635 }
2636 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
2637 config = config.with_report_format(format);
2638 }
2639
2640 let categories = config.categories_to_test();
2642 TerminalReporter::print_success(&format!(
2643 "Testing {} OWASP categories: {}",
2644 categories.len(),
2645 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
2646 ));
2647
2648 if config.valid_auth_token.is_some() {
2649 TerminalReporter::print_progress("Using provided auth token for baseline requests");
2650 }
2651
2652 TerminalReporter::print_progress("Generating OWASP security test script...");
2654 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
2655
2656 let script = generator.generate()?;
2658 TerminalReporter::print_success("OWASP security test script generated");
2659
2660 let script_path = if let Some(output) = &self.script_output {
2662 output.clone()
2663 } else {
2664 self.output.join("k6-owasp-security-test.js")
2665 };
2666
2667 if let Some(parent) = script_path.parent() {
2668 std::fs::create_dir_all(parent)?;
2669 }
2670 std::fs::write(&script_path, &script)?;
2671 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2672
2673 if self.generate_only {
2675 println!("\nOWASP security test script generated. Run it with:");
2676 println!(" k6 run {}", script_path.display());
2677 return Ok(());
2678 }
2679
2680 TerminalReporter::print_progress("Executing OWASP security tests...");
2682 let executor = K6Executor::new()?;
2683 std::fs::create_dir_all(&self.output)?;
2684
2685 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2686
2687 let duration_secs = Self::parse_duration(&self.duration)?;
2688 TerminalReporter::print_summary(&results, duration_secs);
2689
2690 println!("\nOWASP security test results saved to: {}", self.output.display());
2691
2692 Ok(())
2693 }
2694}
2695
2696#[cfg(test)]
2697mod tests {
2698 use super::*;
2699 use tempfile::tempdir;
2700
2701 #[test]
2702 fn test_parse_duration() {
2703 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
2704 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
2705 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
2706 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
2707 }
2708
2709 #[test]
2710 fn test_parse_duration_invalid() {
2711 assert!(BenchCommand::parse_duration("invalid").is_err());
2712 assert!(BenchCommand::parse_duration("30x").is_err());
2713 }
2714
2715 #[test]
2716 fn test_parse_headers() {
2717 let cmd = BenchCommand {
2718 spec: vec![PathBuf::from("test.yaml")],
2719 spec_dir: None,
2720 merge_conflicts: "error".to_string(),
2721 spec_mode: "merge".to_string(),
2722 dependency_config: None,
2723 target: "http://localhost".to_string(),
2724 base_path: None,
2725 duration: "1m".to_string(),
2726 vus: 10,
2727 scenario: "ramp-up".to_string(),
2728 operations: None,
2729 exclude_operations: None,
2730 auth: None,
2731 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
2732 output: PathBuf::from("output"),
2733 generate_only: false,
2734 script_output: None,
2735 threshold_percentile: "p(95)".to_string(),
2736 threshold_ms: 500,
2737 max_error_rate: 0.05,
2738 verbose: false,
2739 skip_tls_verify: false,
2740 targets_file: None,
2741 max_concurrency: None,
2742 results_format: "both".to_string(),
2743 params_file: None,
2744 crud_flow: false,
2745 flow_config: None,
2746 extract_fields: None,
2747 parallel_create: None,
2748 data_file: None,
2749 data_distribution: "unique-per-vu".to_string(),
2750 data_mappings: None,
2751 per_uri_control: false,
2752 error_rate: None,
2753 error_types: None,
2754 security_test: false,
2755 security_payloads: None,
2756 security_categories: None,
2757 security_target_fields: None,
2758 wafbench_dir: None,
2759 wafbench_cycle_all: false,
2760 owasp_api_top10: false,
2761 owasp_categories: None,
2762 owasp_auth_header: "Authorization".to_string(),
2763 owasp_auth_token: None,
2764 owasp_admin_paths: None,
2765 owasp_id_fields: None,
2766 owasp_report: None,
2767 owasp_report_format: "json".to_string(),
2768 owasp_iterations: 1,
2769 conformance: false,
2770 conformance_api_key: None,
2771 conformance_basic_auth: None,
2772 conformance_report: PathBuf::from("conformance-report.json"),
2773 conformance_categories: None,
2774 conformance_report_format: "json".to_string(),
2775 conformance_headers: vec![],
2776 conformance_all_operations: false,
2777 conformance_custom: None,
2778 conformance_delay_ms: 0,
2779 use_k6: false,
2780 conformance_custom_filter: None,
2781 export_requests: false,
2782 };
2783
2784 let headers = cmd.parse_headers().unwrap();
2785 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
2786 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
2787 }
2788
2789 #[test]
2790 fn test_get_spec_display_name() {
2791 let cmd = BenchCommand {
2792 spec: vec![PathBuf::from("test.yaml")],
2793 spec_dir: None,
2794 merge_conflicts: "error".to_string(),
2795 spec_mode: "merge".to_string(),
2796 dependency_config: None,
2797 target: "http://localhost".to_string(),
2798 base_path: None,
2799 duration: "1m".to_string(),
2800 vus: 10,
2801 scenario: "ramp-up".to_string(),
2802 operations: None,
2803 exclude_operations: None,
2804 auth: None,
2805 headers: None,
2806 output: PathBuf::from("output"),
2807 generate_only: false,
2808 script_output: None,
2809 threshold_percentile: "p(95)".to_string(),
2810 threshold_ms: 500,
2811 max_error_rate: 0.05,
2812 verbose: false,
2813 skip_tls_verify: false,
2814 targets_file: None,
2815 max_concurrency: None,
2816 results_format: "both".to_string(),
2817 params_file: None,
2818 crud_flow: false,
2819 flow_config: None,
2820 extract_fields: None,
2821 parallel_create: None,
2822 data_file: None,
2823 data_distribution: "unique-per-vu".to_string(),
2824 data_mappings: None,
2825 per_uri_control: false,
2826 error_rate: None,
2827 error_types: None,
2828 security_test: false,
2829 security_payloads: None,
2830 security_categories: None,
2831 security_target_fields: None,
2832 wafbench_dir: None,
2833 wafbench_cycle_all: false,
2834 owasp_api_top10: false,
2835 owasp_categories: None,
2836 owasp_auth_header: "Authorization".to_string(),
2837 owasp_auth_token: None,
2838 owasp_admin_paths: None,
2839 owasp_id_fields: None,
2840 owasp_report: None,
2841 owasp_report_format: "json".to_string(),
2842 owasp_iterations: 1,
2843 conformance: false,
2844 conformance_api_key: None,
2845 conformance_basic_auth: None,
2846 conformance_report: PathBuf::from("conformance-report.json"),
2847 conformance_categories: None,
2848 conformance_report_format: "json".to_string(),
2849 conformance_headers: vec![],
2850 conformance_all_operations: false,
2851 conformance_custom: None,
2852 conformance_delay_ms: 0,
2853 use_k6: false,
2854 conformance_custom_filter: None,
2855 export_requests: false,
2856 };
2857
2858 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
2859
2860 let cmd_multi = BenchCommand {
2862 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
2863 spec_dir: None,
2864 merge_conflicts: "error".to_string(),
2865 spec_mode: "merge".to_string(),
2866 dependency_config: None,
2867 target: "http://localhost".to_string(),
2868 base_path: None,
2869 duration: "1m".to_string(),
2870 vus: 10,
2871 scenario: "ramp-up".to_string(),
2872 operations: None,
2873 exclude_operations: None,
2874 auth: None,
2875 headers: None,
2876 output: PathBuf::from("output"),
2877 generate_only: false,
2878 script_output: None,
2879 threshold_percentile: "p(95)".to_string(),
2880 threshold_ms: 500,
2881 max_error_rate: 0.05,
2882 verbose: false,
2883 skip_tls_verify: false,
2884 targets_file: None,
2885 max_concurrency: None,
2886 results_format: "both".to_string(),
2887 params_file: None,
2888 crud_flow: false,
2889 flow_config: None,
2890 extract_fields: None,
2891 parallel_create: None,
2892 data_file: None,
2893 data_distribution: "unique-per-vu".to_string(),
2894 data_mappings: None,
2895 per_uri_control: false,
2896 error_rate: None,
2897 error_types: None,
2898 security_test: false,
2899 security_payloads: None,
2900 security_categories: None,
2901 security_target_fields: None,
2902 wafbench_dir: None,
2903 wafbench_cycle_all: false,
2904 owasp_api_top10: false,
2905 owasp_categories: None,
2906 owasp_auth_header: "Authorization".to_string(),
2907 owasp_auth_token: None,
2908 owasp_admin_paths: None,
2909 owasp_id_fields: None,
2910 owasp_report: None,
2911 owasp_report_format: "json".to_string(),
2912 owasp_iterations: 1,
2913 conformance: false,
2914 conformance_api_key: None,
2915 conformance_basic_auth: None,
2916 conformance_report: PathBuf::from("conformance-report.json"),
2917 conformance_categories: None,
2918 conformance_report_format: "json".to_string(),
2919 conformance_headers: vec![],
2920 conformance_all_operations: false,
2921 conformance_custom: None,
2922 conformance_delay_ms: 0,
2923 use_k6: false,
2924 conformance_custom_filter: None,
2925 export_requests: false,
2926 };
2927
2928 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2929 }
2930
2931 #[test]
2932 fn test_parse_extracted_values_from_output_dir() {
2933 let dir = tempdir().unwrap();
2934 let path = dir.path().join("extracted_values.json");
2935 std::fs::write(
2936 &path,
2937 r#"{
2938 "pool_id": "abc123",
2939 "count": 0,
2940 "enabled": false,
2941 "metadata": { "owner": "team-a" }
2942}"#,
2943 )
2944 .unwrap();
2945
2946 let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
2947 assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
2948 assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
2949 assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
2950 assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
2951 }
2952
2953 #[test]
2954 fn test_parse_extracted_values_missing_file() {
2955 let dir = tempdir().unwrap();
2956 let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
2957 assert!(extracted.values.is_empty());
2958 }
2959}