1use crate::crud_flow::{CrudFlowConfig, CrudFlowDetector};
4use crate::data_driven::{DataDistribution, DataDrivenConfig, DataDrivenGenerator, DataMapping};
5use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
6use crate::error::{BenchError, Result};
7use crate::executor::K6Executor;
8use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator};
9use crate::k6_gen::{K6Config, K6ScriptGenerator};
10use crate::mock_integration::{
11 MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
12};
13use crate::owasp_api::{OwaspApiConfig, OwaspApiGenerator, OwaspCategory, ReportFormat};
14use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
15use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
16use crate::param_overrides::ParameterOverrides;
17use crate::reporter::TerminalReporter;
18use crate::request_gen::RequestGenerator;
19use crate::scenarios::LoadScenario;
20use crate::security_payloads::{
21 SecurityCategory, SecurityPayload, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
22};
23use crate::spec_dependencies::{
24 topological_sort, DependencyDetector, ExtractedValues, SpecDependencyConfig,
25};
26use crate::spec_parser::SpecParser;
27use crate::target_parser::parse_targets_file;
28use crate::wafbench::WafBenchLoader;
29use mockforge_core::openapi::multi_spec::{
30 load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_core::openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::PathBuf;
35use std::str::FromStr;
36
37pub struct BenchCommand {
39 pub spec: Vec<PathBuf>,
41 pub spec_dir: Option<PathBuf>,
43 pub merge_conflicts: String,
45 pub spec_mode: String,
47 pub dependency_config: Option<PathBuf>,
49 pub target: String,
50 pub base_path: Option<String>,
53 pub duration: String,
54 pub vus: u32,
55 pub scenario: String,
56 pub operations: Option<String>,
57 pub exclude_operations: Option<String>,
61 pub auth: Option<String>,
62 pub headers: Option<String>,
63 pub output: PathBuf,
64 pub generate_only: bool,
65 pub script_output: Option<PathBuf>,
66 pub threshold_percentile: String,
67 pub threshold_ms: u64,
68 pub max_error_rate: f64,
69 pub verbose: bool,
70 pub skip_tls_verify: bool,
71 pub targets_file: Option<PathBuf>,
73 pub max_concurrency: Option<u32>,
75 pub results_format: String,
77 pub params_file: Option<PathBuf>,
82
83 pub crud_flow: bool,
86 pub flow_config: Option<PathBuf>,
88 pub extract_fields: Option<String>,
90
91 pub parallel_create: Option<u32>,
94
95 pub data_file: Option<PathBuf>,
98 pub data_distribution: String,
100 pub data_mappings: Option<String>,
102 pub per_uri_control: bool,
104
105 pub error_rate: Option<f64>,
108 pub error_types: Option<String>,
110
111 pub security_test: bool,
114 pub security_payloads: Option<PathBuf>,
116 pub security_categories: Option<String>,
118 pub security_target_fields: Option<String>,
120
121 pub wafbench_dir: Option<String>,
124 pub wafbench_cycle_all: bool,
126
127 pub conformance: bool,
130 pub conformance_api_key: Option<String>,
132 pub conformance_basic_auth: Option<String>,
134 pub conformance_report: PathBuf,
136 pub conformance_categories: Option<String>,
138 pub conformance_report_format: String,
140
141 pub owasp_api_top10: bool,
144 pub owasp_categories: Option<String>,
146 pub owasp_auth_header: String,
148 pub owasp_auth_token: Option<String>,
150 pub owasp_admin_paths: Option<PathBuf>,
152 pub owasp_id_fields: Option<String>,
154 pub owasp_report: Option<PathBuf>,
156 pub owasp_report_format: String,
158 pub owasp_iterations: u32,
160}
161
162impl BenchCommand {
163 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
165 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
166
167 if !self.spec.is_empty() {
169 let specs = load_specs_from_files(self.spec.clone())
170 .await
171 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
172 all_specs.extend(specs);
173 }
174
175 if let Some(spec_dir) = &self.spec_dir {
177 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
178 BenchError::Other(format!("Failed to load specs from directory: {}", e))
179 })?;
180 all_specs.extend(dir_specs);
181 }
182
183 if all_specs.is_empty() {
184 return Err(BenchError::Other(
185 "No spec files provided. Use --spec or --spec-dir.".to_string(),
186 ));
187 }
188
189 if all_specs.len() == 1 {
191 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
193 }
194
195 let conflict_strategy = match self.merge_conflicts.as_str() {
197 "first" => ConflictStrategy::First,
198 "last" => ConflictStrategy::Last,
199 _ => ConflictStrategy::Error,
200 };
201
202 merge_specs(all_specs, conflict_strategy)
203 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
204 }
205
206 fn get_spec_display_name(&self) -> String {
208 if self.spec.len() == 1 {
209 self.spec[0].to_string_lossy().to_string()
210 } else if !self.spec.is_empty() {
211 format!("{} spec files", self.spec.len())
212 } else if let Some(dir) = &self.spec_dir {
213 format!("specs from {}", dir.display())
214 } else {
215 "no specs".to_string()
216 }
217 }
218
219 pub async fn execute(&self) -> Result<()> {
221 if let Some(targets_file) = &self.targets_file {
223 return self.execute_multi_target(targets_file).await;
224 }
225
226 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
228 return self.execute_sequential_specs().await;
229 }
230
231 TerminalReporter::print_header(
234 &self.get_spec_display_name(),
235 &self.target,
236 0, &self.scenario,
238 Self::parse_duration(&self.duration)?,
239 );
240
241 if !K6Executor::is_k6_installed() {
243 TerminalReporter::print_error("k6 is not installed");
244 TerminalReporter::print_warning(
245 "Install k6 from: https://k6.io/docs/get-started/installation/",
246 );
247 return Err(BenchError::K6NotFound);
248 }
249
250 if self.conformance {
252 return self.execute_conformance_test().await;
253 }
254
255 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
257 let merged_spec = self.load_and_merge_specs().await?;
258 let parser = SpecParser::from_spec(merged_spec);
259 if self.spec.len() > 1 || self.spec_dir.is_some() {
260 TerminalReporter::print_success(&format!(
261 "Loaded and merged {} specification(s)",
262 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
263 ));
264 } else {
265 TerminalReporter::print_success("Specification loaded");
266 }
267
268 let mock_config = self.build_mock_config().await;
270 if mock_config.is_mock_server {
271 TerminalReporter::print_progress("Mock server integration enabled");
272 }
273
274 if self.crud_flow {
276 return self.execute_crud_flow(&parser).await;
277 }
278
279 if self.owasp_api_top10 {
281 return self.execute_owasp_test(&parser).await;
282 }
283
284 TerminalReporter::print_progress("Extracting API operations...");
286 let mut operations = if let Some(filter) = &self.operations {
287 parser.filter_operations(filter)?
288 } else {
289 parser.get_operations()
290 };
291
292 if let Some(exclude) = &self.exclude_operations {
294 let before_count = operations.len();
295 operations = parser.exclude_operations(operations, exclude)?;
296 let excluded_count = before_count - operations.len();
297 if excluded_count > 0 {
298 TerminalReporter::print_progress(&format!(
299 "Excluded {} operations matching '{}'",
300 excluded_count, exclude
301 ));
302 }
303 }
304
305 if operations.is_empty() {
306 return Err(BenchError::Other("No operations found in spec".to_string()));
307 }
308
309 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
310
311 let param_overrides = if let Some(params_file) = &self.params_file {
313 TerminalReporter::print_progress("Loading parameter overrides...");
314 let overrides = ParameterOverrides::from_file(params_file)?;
315 TerminalReporter::print_success(&format!(
316 "Loaded parameter overrides ({} operation-specific, {} defaults)",
317 overrides.operations.len(),
318 if overrides.defaults.is_empty() { 0 } else { 1 }
319 ));
320 Some(overrides)
321 } else {
322 None
323 };
324
325 TerminalReporter::print_progress("Generating request templates...");
327 let templates: Vec<_> = operations
328 .iter()
329 .map(|op| {
330 let op_overrides = param_overrides.as_ref().map(|po| {
331 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
332 });
333 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
334 })
335 .collect::<Result<Vec<_>>>()?;
336 TerminalReporter::print_success("Request templates generated");
337
338 let custom_headers = self.parse_headers()?;
340
341 let base_path = self.resolve_base_path(&parser);
343 if let Some(ref bp) = base_path {
344 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
345 }
346
347 TerminalReporter::print_progress("Generating k6 load test script...");
349 let scenario =
350 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
351
352 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
353
354 let k6_config = K6Config {
355 target_url: self.target.clone(),
356 base_path,
357 scenario,
358 duration_secs: Self::parse_duration(&self.duration)?,
359 max_vus: self.vus,
360 threshold_percentile: self.threshold_percentile.clone(),
361 threshold_ms: self.threshold_ms,
362 max_error_rate: self.max_error_rate,
363 auth_header: self.auth.clone(),
364 custom_headers,
365 skip_tls_verify: self.skip_tls_verify,
366 security_testing_enabled,
367 };
368
369 let generator = K6ScriptGenerator::new(k6_config, templates);
370 let mut script = generator.generate()?;
371 TerminalReporter::print_success("k6 script generated");
372
373 let has_advanced_features = self.data_file.is_some()
375 || self.error_rate.is_some()
376 || self.security_test
377 || self.parallel_create.is_some()
378 || self.wafbench_dir.is_some();
379
380 if has_advanced_features {
382 script = self.generate_enhanced_script(&script)?;
383 }
384
385 if mock_config.is_mock_server {
387 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
388 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
389 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
390
391 if let Some(import_end) = script.find("export const options") {
393 script.insert_str(
394 import_end,
395 &format!(
396 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
397 helper_code, setup_code, teardown_code
398 ),
399 );
400 }
401 }
402
403 TerminalReporter::print_progress("Validating k6 script...");
405 let validation_errors = K6ScriptGenerator::validate_script(&script);
406 if !validation_errors.is_empty() {
407 TerminalReporter::print_error("Script validation failed");
408 for error in &validation_errors {
409 eprintln!(" {}", error);
410 }
411 return Err(BenchError::Other(format!(
412 "Generated k6 script has {} validation error(s). Please check the output above.",
413 validation_errors.len()
414 )));
415 }
416 TerminalReporter::print_success("Script validation passed");
417
418 let script_path = if let Some(output) = &self.script_output {
420 output.clone()
421 } else {
422 self.output.join("k6-script.js")
423 };
424
425 if let Some(parent) = script_path.parent() {
426 std::fs::create_dir_all(parent)?;
427 }
428 std::fs::write(&script_path, &script)?;
429 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
430
431 if self.generate_only {
433 println!("\nScript generated successfully. Run it with:");
434 println!(" k6 run {}", script_path.display());
435 return Ok(());
436 }
437
438 TerminalReporter::print_progress("Executing load test...");
440 let executor = K6Executor::new()?;
441
442 std::fs::create_dir_all(&self.output)?;
443
444 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
445
446 let duration_secs = Self::parse_duration(&self.duration)?;
448 TerminalReporter::print_summary(&results, duration_secs);
449
450 println!("\nResults saved to: {}", self.output.display());
451
452 Ok(())
453 }
454
455 async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
457 TerminalReporter::print_progress("Parsing targets file...");
458 let targets = parse_targets_file(targets_file)?;
459 let num_targets = targets.len();
460 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
461
462 if targets.is_empty() {
463 return Err(BenchError::Other("No targets found in file".to_string()));
464 }
465
466 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
468 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
472 &self.get_spec_display_name(),
473 &format!("{} targets", num_targets),
474 0,
475 &self.scenario,
476 Self::parse_duration(&self.duration)?,
477 );
478
479 let executor = ParallelExecutor::new(
481 BenchCommand {
482 spec: self.spec.clone(),
484 spec_dir: self.spec_dir.clone(),
485 merge_conflicts: self.merge_conflicts.clone(),
486 spec_mode: self.spec_mode.clone(),
487 dependency_config: self.dependency_config.clone(),
488 target: self.target.clone(), base_path: self.base_path.clone(),
490 duration: self.duration.clone(),
491 vus: self.vus,
492 scenario: self.scenario.clone(),
493 operations: self.operations.clone(),
494 exclude_operations: self.exclude_operations.clone(),
495 auth: self.auth.clone(),
496 headers: self.headers.clone(),
497 output: self.output.clone(),
498 generate_only: self.generate_only,
499 script_output: self.script_output.clone(),
500 threshold_percentile: self.threshold_percentile.clone(),
501 threshold_ms: self.threshold_ms,
502 max_error_rate: self.max_error_rate,
503 verbose: self.verbose,
504 skip_tls_verify: self.skip_tls_verify,
505 targets_file: None,
506 max_concurrency: None,
507 results_format: self.results_format.clone(),
508 params_file: self.params_file.clone(),
509 crud_flow: self.crud_flow,
510 flow_config: self.flow_config.clone(),
511 extract_fields: self.extract_fields.clone(),
512 parallel_create: self.parallel_create,
513 data_file: self.data_file.clone(),
514 data_distribution: self.data_distribution.clone(),
515 data_mappings: self.data_mappings.clone(),
516 per_uri_control: self.per_uri_control,
517 error_rate: self.error_rate,
518 error_types: self.error_types.clone(),
519 security_test: self.security_test,
520 security_payloads: self.security_payloads.clone(),
521 security_categories: self.security_categories.clone(),
522 security_target_fields: self.security_target_fields.clone(),
523 wafbench_dir: self.wafbench_dir.clone(),
524 wafbench_cycle_all: self.wafbench_cycle_all,
525 owasp_api_top10: self.owasp_api_top10,
526 owasp_categories: self.owasp_categories.clone(),
527 owasp_auth_header: self.owasp_auth_header.clone(),
528 owasp_auth_token: self.owasp_auth_token.clone(),
529 owasp_admin_paths: self.owasp_admin_paths.clone(),
530 owasp_id_fields: self.owasp_id_fields.clone(),
531 owasp_report: self.owasp_report.clone(),
532 owasp_report_format: self.owasp_report_format.clone(),
533 owasp_iterations: self.owasp_iterations,
534 conformance: false,
535 conformance_api_key: None,
536 conformance_basic_auth: None,
537 conformance_report: PathBuf::from("conformance-report.json"),
538 conformance_categories: None,
539 conformance_report_format: "json".to_string(),
540 },
541 targets,
542 max_concurrency,
543 );
544
545 let aggregated_results = executor.execute_all().await?;
547
548 self.report_multi_target_results(&aggregated_results)?;
550
551 Ok(())
552 }
553
554 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
556 TerminalReporter::print_multi_target_summary(results);
558
559 if self.results_format == "aggregated" || self.results_format == "both" {
561 let summary_path = self.output.join("aggregated_summary.json");
562 let summary_json = serde_json::json!({
563 "total_targets": results.total_targets,
564 "successful_targets": results.successful_targets,
565 "failed_targets": results.failed_targets,
566 "aggregated_metrics": {
567 "total_requests": results.aggregated_metrics.total_requests,
568 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
569 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
570 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
571 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
572 "error_rate": results.aggregated_metrics.error_rate,
573 "total_rps": results.aggregated_metrics.total_rps,
574 "avg_rps": results.aggregated_metrics.avg_rps,
575 "total_vus_max": results.aggregated_metrics.total_vus_max,
576 },
577 "target_results": results.target_results.iter().map(|r| {
578 serde_json::json!({
579 "target_url": r.target_url,
580 "target_index": r.target_index,
581 "success": r.success,
582 "error": r.error,
583 "total_requests": r.results.total_requests,
584 "failed_requests": r.results.failed_requests,
585 "avg_duration_ms": r.results.avg_duration_ms,
586 "min_duration_ms": r.results.min_duration_ms,
587 "med_duration_ms": r.results.med_duration_ms,
588 "p90_duration_ms": r.results.p90_duration_ms,
589 "p95_duration_ms": r.results.p95_duration_ms,
590 "p99_duration_ms": r.results.p99_duration_ms,
591 "max_duration_ms": r.results.max_duration_ms,
592 "rps": r.results.rps,
593 "vus_max": r.results.vus_max,
594 "output_dir": r.output_dir.to_string_lossy(),
595 })
596 }).collect::<Vec<_>>(),
597 });
598
599 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
600 TerminalReporter::print_success(&format!(
601 "Aggregated summary saved to: {}",
602 summary_path.display()
603 ));
604 }
605
606 println!("\nResults saved to: {}", self.output.display());
607 println!(" - Per-target results: {}", self.output.join("target_*").display());
608 if self.results_format == "aggregated" || self.results_format == "both" {
609 println!(
610 " - Aggregated summary: {}",
611 self.output.join("aggregated_summary.json").display()
612 );
613 }
614
615 Ok(())
616 }
617
618 pub fn parse_duration(duration: &str) -> Result<u64> {
620 let duration = duration.trim();
621
622 if let Some(secs) = duration.strip_suffix('s') {
623 secs.parse::<u64>()
624 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
625 } else if let Some(mins) = duration.strip_suffix('m') {
626 mins.parse::<u64>()
627 .map(|m| m * 60)
628 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
629 } else if let Some(hours) = duration.strip_suffix('h') {
630 hours
631 .parse::<u64>()
632 .map(|h| h * 3600)
633 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
634 } else {
635 duration
637 .parse::<u64>()
638 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
639 }
640 }
641
642 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
644 let mut headers = HashMap::new();
645
646 if let Some(header_str) = &self.headers {
647 for pair in header_str.split(',') {
648 let parts: Vec<&str> = pair.splitn(2, ':').collect();
649 if parts.len() != 2 {
650 return Err(BenchError::Other(format!(
651 "Invalid header format: '{}'. Expected 'Key:Value'",
652 pair
653 )));
654 }
655 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
656 }
657 }
658
659 Ok(headers)
660 }
661
662 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
671 if let Some(cli_base_path) = &self.base_path {
673 if cli_base_path.is_empty() {
674 return None;
676 }
677 return Some(cli_base_path.clone());
678 }
679
680 parser.get_base_path()
682 }
683
684 async fn build_mock_config(&self) -> MockIntegrationConfig {
686 if MockServerDetector::looks_like_mock_server(&self.target) {
688 if let Ok(info) = MockServerDetector::detect(&self.target).await {
690 if info.is_mockforge {
691 TerminalReporter::print_success(&format!(
692 "Detected MockForge server (version: {})",
693 info.version.as_deref().unwrap_or("unknown")
694 ));
695 return MockIntegrationConfig::mock_server();
696 }
697 }
698 }
699 MockIntegrationConfig::real_api()
700 }
701
702 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
704 if !self.crud_flow {
705 return None;
706 }
707
708 if let Some(config_path) = &self.flow_config {
710 match CrudFlowConfig::from_file(config_path) {
711 Ok(config) => return Some(config),
712 Err(e) => {
713 TerminalReporter::print_warning(&format!(
714 "Failed to load flow config: {}. Using auto-detection.",
715 e
716 ));
717 }
718 }
719 }
720
721 let extract_fields = self
723 .extract_fields
724 .as_ref()
725 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
726 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
727
728 Some(CrudFlowConfig {
729 flows: Vec::new(), default_extract_fields: extract_fields,
731 })
732 }
733
734 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
736 let data_file = self.data_file.as_ref()?;
737
738 let distribution = DataDistribution::from_str(&self.data_distribution)
739 .unwrap_or(DataDistribution::UniquePerVu);
740
741 let mappings = self
742 .data_mappings
743 .as_ref()
744 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
745 .unwrap_or_default();
746
747 Some(DataDrivenConfig {
748 file_path: data_file.to_string_lossy().to_string(),
749 distribution,
750 mappings,
751 csv_has_header: true,
752 per_uri_control: self.per_uri_control,
753 per_uri_columns: crate::data_driven::PerUriColumns::default(),
754 })
755 }
756
757 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
759 let error_rate = self.error_rate?;
760
761 let error_types = self
762 .error_types
763 .as_ref()
764 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
765 .unwrap_or_default();
766
767 Some(InvalidDataConfig {
768 error_rate,
769 error_types,
770 target_fields: Vec::new(),
771 })
772 }
773
774 fn build_security_config(&self) -> Option<SecurityTestConfig> {
776 if !self.security_test {
777 return None;
778 }
779
780 let categories = self
781 .security_categories
782 .as_ref()
783 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
784 .unwrap_or_else(|| {
785 let mut default = std::collections::HashSet::new();
786 default.insert(SecurityCategory::SqlInjection);
787 default.insert(SecurityCategory::Xss);
788 default
789 });
790
791 let target_fields = self
792 .security_target_fields
793 .as_ref()
794 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
795 .unwrap_or_default();
796
797 let custom_payloads_file =
798 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
799
800 Some(SecurityTestConfig {
801 enabled: true,
802 categories,
803 target_fields,
804 custom_payloads_file,
805 include_high_risk: false,
806 })
807 }
808
809 fn build_parallel_config(&self) -> Option<ParallelConfig> {
811 let count = self.parallel_create?;
812
813 Some(ParallelConfig::new(count))
814 }
815
816 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
818 let Some(ref wafbench_dir) = self.wafbench_dir else {
819 return Vec::new();
820 };
821
822 let mut loader = WafBenchLoader::new();
823
824 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
825 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
826 return Vec::new();
827 }
828
829 let stats = loader.stats();
830
831 if stats.files_processed == 0 {
832 TerminalReporter::print_warning(&format!(
833 "No WAFBench YAML files found matching '{}'",
834 wafbench_dir
835 ));
836 if !stats.parse_errors.is_empty() {
838 TerminalReporter::print_warning("Some files were found but failed to parse:");
839 for error in &stats.parse_errors {
840 TerminalReporter::print_warning(&format!(" - {}", error));
841 }
842 }
843 return Vec::new();
844 }
845
846 TerminalReporter::print_progress(&format!(
847 "Loaded {} WAFBench files, {} test cases, {} payloads",
848 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
849 ));
850
851 for (category, count) in &stats.by_category {
853 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
854 }
855
856 for error in &stats.parse_errors {
858 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
859 }
860
861 loader.to_security_payloads()
862 }
863
864 pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
866 let mut enhanced_script = base_script.to_string();
867 let mut additional_code = String::new();
868
869 if let Some(config) = self.build_data_driven_config() {
871 TerminalReporter::print_progress("Adding data-driven testing support...");
872 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
873 additional_code.push('\n');
874 TerminalReporter::print_success("Data-driven testing enabled");
875 }
876
877 if let Some(config) = self.build_invalid_data_config() {
879 TerminalReporter::print_progress("Adding invalid data testing support...");
880 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
881 additional_code.push('\n');
882 additional_code
883 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
884 additional_code.push('\n');
885 additional_code
886 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
887 additional_code.push('\n');
888 TerminalReporter::print_success(&format!(
889 "Invalid data testing enabled ({}% error rate)",
890 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
891 ));
892 }
893
894 let security_config = self.build_security_config();
896 let wafbench_payloads = self.load_wafbench_payloads();
897 let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
898
899 if security_config.is_some() || !wafbench_payloads.is_empty() {
900 TerminalReporter::print_progress("Adding security testing support...");
901
902 let mut payload_list: Vec<SecurityPayload> = Vec::new();
904
905 if let Some(ref config) = security_config {
906 payload_list.extend(SecurityPayloads::get_payloads(config));
907 }
908
909 if !wafbench_payloads.is_empty() {
911 TerminalReporter::print_progress(&format!(
912 "Loading {} WAFBench attack patterns...",
913 wafbench_payloads.len()
914 ));
915 payload_list.extend(wafbench_payloads);
916 }
917
918 let target_fields =
919 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
920
921 additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
922 &payload_list,
923 self.wafbench_cycle_all,
924 ));
925 additional_code.push('\n');
926 additional_code
927 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
928 additional_code.push('\n');
929 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
930 additional_code.push('\n');
931
932 let mode = if self.wafbench_cycle_all {
933 "cycle-all"
934 } else {
935 "random"
936 };
937 TerminalReporter::print_success(&format!(
938 "Security testing enabled ({} payloads, {} mode)",
939 payload_list.len(),
940 mode
941 ));
942 } else if security_requested {
943 TerminalReporter::print_warning(
947 "Security testing was requested but no payloads were loaded. \
948 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
949 );
950 additional_code
951 .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
952 additional_code.push('\n');
953 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
954 additional_code.push('\n');
955 }
956
957 if let Some(config) = self.build_parallel_config() {
959 TerminalReporter::print_progress("Adding parallel execution support...");
960 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
961 additional_code.push('\n');
962 TerminalReporter::print_success(&format!(
963 "Parallel execution enabled (count: {})",
964 config.count
965 ));
966 }
967
968 if !additional_code.is_empty() {
970 if let Some(import_end) = enhanced_script.find("export const options") {
972 enhanced_script.insert_str(
973 import_end,
974 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
975 );
976 }
977 }
978
979 Ok(enhanced_script)
980 }
981
982 async fn execute_sequential_specs(&self) -> Result<()> {
984 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
985
986 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
988
989 if !self.spec.is_empty() {
990 let specs = load_specs_from_files(self.spec.clone())
991 .await
992 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
993 all_specs.extend(specs);
994 }
995
996 if let Some(spec_dir) = &self.spec_dir {
997 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
998 BenchError::Other(format!("Failed to load specs from directory: {}", e))
999 })?;
1000 all_specs.extend(dir_specs);
1001 }
1002
1003 if all_specs.is_empty() {
1004 return Err(BenchError::Other(
1005 "No spec files found for sequential execution".to_string(),
1006 ));
1007 }
1008
1009 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1010
1011 let execution_order = if let Some(config_path) = &self.dependency_config {
1013 TerminalReporter::print_progress("Loading dependency configuration...");
1014 let config = SpecDependencyConfig::from_file(config_path)?;
1015
1016 if !config.disable_auto_detect && config.execution_order.is_empty() {
1017 self.detect_and_sort_specs(&all_specs)?
1019 } else {
1020 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1022 }
1023 } else {
1024 self.detect_and_sort_specs(&all_specs)?
1026 };
1027
1028 TerminalReporter::print_success(&format!(
1029 "Execution order: {}",
1030 execution_order
1031 .iter()
1032 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1033 .collect::<Vec<_>>()
1034 .join(" → ")
1035 ));
1036
1037 let mut extracted_values = ExtractedValues::new();
1039 let total_specs = execution_order.len();
1040
1041 for (index, spec_path) in execution_order.iter().enumerate() {
1042 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1043
1044 TerminalReporter::print_progress(&format!(
1045 "[{}/{}] Executing spec: {}",
1046 index + 1,
1047 total_specs,
1048 spec_name
1049 ));
1050
1051 let spec = all_specs
1053 .iter()
1054 .find(|(p, _)| {
1055 p == spec_path
1056 || p.file_name() == spec_path.file_name()
1057 || p.file_name() == Some(spec_path.as_os_str())
1058 })
1059 .map(|(_, s)| s.clone())
1060 .ok_or_else(|| {
1061 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1062 })?;
1063
1064 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1066
1067 extracted_values.merge(&new_values);
1069
1070 TerminalReporter::print_success(&format!(
1071 "[{}/{}] Completed: {} (extracted {} values)",
1072 index + 1,
1073 total_specs,
1074 spec_name,
1075 new_values.values.len()
1076 ));
1077 }
1078
1079 TerminalReporter::print_success(&format!(
1080 "Sequential execution complete: {} specs executed",
1081 total_specs
1082 ));
1083
1084 Ok(())
1085 }
1086
1087 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1089 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1090
1091 let mut detector = DependencyDetector::new();
1092 let dependencies = detector.detect_dependencies(specs);
1093
1094 if dependencies.is_empty() {
1095 TerminalReporter::print_progress("No dependencies detected, using file order");
1096 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1097 }
1098
1099 TerminalReporter::print_progress(&format!(
1100 "Detected {} cross-spec dependencies",
1101 dependencies.len()
1102 ));
1103
1104 for dep in &dependencies {
1105 TerminalReporter::print_progress(&format!(
1106 " {} → {} (via field '{}')",
1107 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1108 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1109 dep.field_name
1110 ));
1111 }
1112
1113 topological_sort(specs, &dependencies)
1114 }
1115
1116 async fn execute_single_spec(
1118 &self,
1119 spec: &OpenApiSpec,
1120 spec_name: &str,
1121 _external_values: &ExtractedValues,
1122 ) -> Result<ExtractedValues> {
1123 let parser = SpecParser::from_spec(spec.clone());
1124
1125 if self.crud_flow {
1127 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1129 } else {
1130 self.execute_standard_spec(&parser, spec_name).await?;
1132 Ok(ExtractedValues::new())
1133 }
1134 }
1135
1136 async fn execute_crud_flow_with_extraction(
1138 &self,
1139 parser: &SpecParser,
1140 spec_name: &str,
1141 ) -> Result<ExtractedValues> {
1142 let operations = parser.get_operations();
1143 let flows = CrudFlowDetector::detect_flows(&operations);
1144
1145 if flows.is_empty() {
1146 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1147 return Ok(ExtractedValues::new());
1148 }
1149
1150 TerminalReporter::print_progress(&format!(
1151 " {} CRUD flow(s) in {}",
1152 flows.len(),
1153 spec_name
1154 ));
1155
1156 let mut handlebars = handlebars::Handlebars::new();
1158 handlebars.register_helper(
1160 "json",
1161 Box::new(
1162 |h: &handlebars::Helper,
1163 _: &handlebars::Handlebars,
1164 _: &handlebars::Context,
1165 _: &mut handlebars::RenderContext,
1166 out: &mut dyn handlebars::Output|
1167 -> handlebars::HelperResult {
1168 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1169 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1170 Ok(())
1171 },
1172 ),
1173 );
1174 let template = include_str!("templates/k6_crud_flow.hbs");
1175
1176 let custom_headers = self.parse_headers()?;
1177 let config = self.build_crud_flow_config().unwrap_or_default();
1178
1179 let param_overrides = if let Some(params_file) = &self.params_file {
1181 let overrides = ParameterOverrides::from_file(params_file)?;
1182 Some(overrides)
1183 } else {
1184 None
1185 };
1186
1187 let duration_secs = Self::parse_duration(&self.duration)?;
1189 let scenario =
1190 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1191 let stages = scenario.generate_stages(duration_secs, self.vus);
1192
1193 let api_base_path = self.resolve_base_path(parser);
1195
1196 let mut all_headers = custom_headers.clone();
1198 if let Some(auth) = &self.auth {
1199 all_headers.insert("Authorization".to_string(), auth.clone());
1200 }
1201 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1202
1203 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1205
1206 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1207 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1208 serde_json::json!({
1209 "name": sanitized_name.clone(),
1210 "display_name": f.name,
1211 "base_path": f.base_path,
1212 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1213 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1215 let method_raw = if !parts.is_empty() {
1216 parts[0].to_uppercase()
1217 } else {
1218 "GET".to_string()
1219 };
1220 let method = if !parts.is_empty() {
1221 let m = parts[0].to_lowercase();
1222 if m == "delete" { "del".to_string() } else { m }
1224 } else {
1225 "get".to_string()
1226 };
1227 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1228 let path = if let Some(ref bp) = api_base_path {
1230 format!("{}{}", bp, raw_path)
1231 } else {
1232 raw_path.to_string()
1233 };
1234 let is_get_or_head = method == "get" || method == "head";
1235 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1237
1238 let body_value = if has_body {
1240 param_overrides.as_ref()
1241 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1242 .and_then(|oo| oo.body)
1243 .unwrap_or_else(|| serde_json::json!({}))
1244 } else {
1245 serde_json::json!({})
1246 };
1247
1248 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1250
1251 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1253 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1254
1255 serde_json::json!({
1256 "operation": s.operation,
1257 "method": method,
1258 "path": path,
1259 "extract": s.extract,
1260 "use_values": s.use_values,
1261 "use_body": s.use_body,
1262 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1263 "inject_attacks": s.inject_attacks,
1264 "attack_types": s.attack_types,
1265 "description": s.description,
1266 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1267 "is_get_or_head": is_get_or_head,
1268 "has_body": has_body,
1269 "body": processed_body.value,
1270 "body_is_dynamic": body_is_dynamic,
1271 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1272 })
1273 }).collect::<Vec<_>>(),
1274 })
1275 }).collect();
1276
1277 for flow_data in &flows_data {
1279 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1280 for step in steps {
1281 if let Some(placeholders_arr) =
1282 step.get("_placeholders").and_then(|p| p.as_array())
1283 {
1284 for p_str in placeholders_arr {
1285 if let Some(p_name) = p_str.as_str() {
1286 match p_name {
1287 "VU" => {
1288 all_placeholders.insert(DynamicPlaceholder::VU);
1289 }
1290 "Iteration" => {
1291 all_placeholders.insert(DynamicPlaceholder::Iteration);
1292 }
1293 "Timestamp" => {
1294 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1295 }
1296 "UUID" => {
1297 all_placeholders.insert(DynamicPlaceholder::UUID);
1298 }
1299 "Random" => {
1300 all_placeholders.insert(DynamicPlaceholder::Random);
1301 }
1302 "Counter" => {
1303 all_placeholders.insert(DynamicPlaceholder::Counter);
1304 }
1305 "Date" => {
1306 all_placeholders.insert(DynamicPlaceholder::Date);
1307 }
1308 "VuIter" => {
1309 all_placeholders.insert(DynamicPlaceholder::VuIter);
1310 }
1311 _ => {}
1312 }
1313 }
1314 }
1315 }
1316 }
1317 }
1318 }
1319
1320 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1322 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1323
1324 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1326
1327 let data = serde_json::json!({
1328 "base_url": self.target,
1329 "flows": flows_data,
1330 "extract_fields": config.default_extract_fields,
1331 "duration_secs": duration_secs,
1332 "max_vus": self.vus,
1333 "auth_header": self.auth,
1334 "custom_headers": custom_headers,
1335 "skip_tls_verify": self.skip_tls_verify,
1336 "stages": stages.iter().map(|s| serde_json::json!({
1338 "duration": s.duration,
1339 "target": s.target,
1340 })).collect::<Vec<_>>(),
1341 "threshold_percentile": self.threshold_percentile,
1342 "threshold_ms": self.threshold_ms,
1343 "max_error_rate": self.max_error_rate,
1344 "headers": headers_json,
1345 "dynamic_imports": required_imports,
1346 "dynamic_globals": required_globals,
1347 "security_testing_enabled": security_testing_enabled,
1349 "has_custom_headers": !custom_headers.is_empty(),
1350 });
1351
1352 let mut script = handlebars
1353 .render_template(template, &data)
1354 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1355
1356 if security_testing_enabled {
1358 script = self.generate_enhanced_script(&script)?;
1359 }
1360
1361 let script_path =
1363 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1364
1365 std::fs::create_dir_all(self.output.clone())?;
1366 std::fs::write(&script_path, &script)?;
1367
1368 if !self.generate_only {
1369 let executor = K6Executor::new()?;
1370 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1371 std::fs::create_dir_all(&output_dir)?;
1372
1373 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1374 }
1375
1376 Ok(ExtractedValues::new())
1379 }
1380
1381 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1383 let mut operations = if let Some(filter) = &self.operations {
1384 parser.filter_operations(filter)?
1385 } else {
1386 parser.get_operations()
1387 };
1388
1389 if let Some(exclude) = &self.exclude_operations {
1390 operations = parser.exclude_operations(operations, exclude)?;
1391 }
1392
1393 if operations.is_empty() {
1394 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1395 return Ok(());
1396 }
1397
1398 TerminalReporter::print_progress(&format!(
1399 " {} operations in {}",
1400 operations.len(),
1401 spec_name
1402 ));
1403
1404 let templates: Vec<_> = operations
1406 .iter()
1407 .map(RequestGenerator::generate_template)
1408 .collect::<Result<Vec<_>>>()?;
1409
1410 let custom_headers = self.parse_headers()?;
1412
1413 let base_path = self.resolve_base_path(parser);
1415
1416 let scenario =
1418 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1419
1420 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
1421
1422 let k6_config = K6Config {
1423 target_url: self.target.clone(),
1424 base_path,
1425 scenario,
1426 duration_secs: Self::parse_duration(&self.duration)?,
1427 max_vus: self.vus,
1428 threshold_percentile: self.threshold_percentile.clone(),
1429 threshold_ms: self.threshold_ms,
1430 max_error_rate: self.max_error_rate,
1431 auth_header: self.auth.clone(),
1432 custom_headers,
1433 skip_tls_verify: self.skip_tls_verify,
1434 security_testing_enabled,
1435 };
1436
1437 let generator = K6ScriptGenerator::new(k6_config, templates);
1438 let mut script = generator.generate()?;
1439
1440 let has_advanced_features = self.data_file.is_some()
1442 || self.error_rate.is_some()
1443 || self.security_test
1444 || self.parallel_create.is_some()
1445 || self.wafbench_dir.is_some();
1446
1447 if has_advanced_features {
1448 script = self.generate_enhanced_script(&script)?;
1449 }
1450
1451 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1453
1454 std::fs::create_dir_all(self.output.clone())?;
1455 std::fs::write(&script_path, &script)?;
1456
1457 if !self.generate_only {
1458 let executor = K6Executor::new()?;
1459 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1460 std::fs::create_dir_all(&output_dir)?;
1461
1462 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1463 }
1464
1465 Ok(())
1466 }
1467
1468 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1470 let config = self.build_crud_flow_config().unwrap_or_default();
1472
1473 let flows = if !config.flows.is_empty() {
1475 TerminalReporter::print_progress("Using custom flow configuration...");
1476 config.flows.clone()
1477 } else {
1478 TerminalReporter::print_progress("Detecting CRUD operations...");
1479 let operations = parser.get_operations();
1480 CrudFlowDetector::detect_flows(&operations)
1481 };
1482
1483 if flows.is_empty() {
1484 return Err(BenchError::Other(
1485 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1486 ));
1487 }
1488
1489 if config.flows.is_empty() {
1490 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1491 } else {
1492 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1493 }
1494
1495 for flow in &flows {
1496 TerminalReporter::print_progress(&format!(
1497 " - {}: {} steps",
1498 flow.name,
1499 flow.steps.len()
1500 ));
1501 }
1502
1503 let mut handlebars = handlebars::Handlebars::new();
1505 handlebars.register_helper(
1507 "json",
1508 Box::new(
1509 |h: &handlebars::Helper,
1510 _: &handlebars::Handlebars,
1511 _: &handlebars::Context,
1512 _: &mut handlebars::RenderContext,
1513 out: &mut dyn handlebars::Output|
1514 -> handlebars::HelperResult {
1515 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1516 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1517 Ok(())
1518 },
1519 ),
1520 );
1521 let template = include_str!("templates/k6_crud_flow.hbs");
1522
1523 let custom_headers = self.parse_headers()?;
1524
1525 let param_overrides = if let Some(params_file) = &self.params_file {
1527 TerminalReporter::print_progress("Loading parameter overrides...");
1528 let overrides = ParameterOverrides::from_file(params_file)?;
1529 TerminalReporter::print_success(&format!(
1530 "Loaded parameter overrides ({} operation-specific, {} defaults)",
1531 overrides.operations.len(),
1532 if overrides.defaults.is_empty() { 0 } else { 1 }
1533 ));
1534 Some(overrides)
1535 } else {
1536 None
1537 };
1538
1539 let duration_secs = Self::parse_duration(&self.duration)?;
1541 let scenario =
1542 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1543 let stages = scenario.generate_stages(duration_secs, self.vus);
1544
1545 let api_base_path = self.resolve_base_path(parser);
1547 if let Some(ref bp) = api_base_path {
1548 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1549 }
1550
1551 let mut all_headers = custom_headers.clone();
1553 if let Some(auth) = &self.auth {
1554 all_headers.insert("Authorization".to_string(), auth.clone());
1555 }
1556 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1557
1558 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1560
1561 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1562 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1564 serde_json::json!({
1565 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1568 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1569 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1571 let method_raw = if !parts.is_empty() {
1572 parts[0].to_uppercase()
1573 } else {
1574 "GET".to_string()
1575 };
1576 let method = if !parts.is_empty() {
1577 let m = parts[0].to_lowercase();
1578 if m == "delete" { "del".to_string() } else { m }
1580 } else {
1581 "get".to_string()
1582 };
1583 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1584 let path = if let Some(ref bp) = api_base_path {
1586 format!("{}{}", bp, raw_path)
1587 } else {
1588 raw_path.to_string()
1589 };
1590 let is_get_or_head = method == "get" || method == "head";
1591 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1593
1594 let body_value = if has_body {
1596 param_overrides.as_ref()
1597 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1598 .and_then(|oo| oo.body)
1599 .unwrap_or_else(|| serde_json::json!({}))
1600 } else {
1601 serde_json::json!({})
1602 };
1603
1604 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1606 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1611 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1612
1613 serde_json::json!({
1614 "operation": s.operation,
1615 "method": method,
1616 "path": path,
1617 "extract": s.extract,
1618 "use_values": s.use_values,
1619 "use_body": s.use_body,
1620 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1621 "inject_attacks": s.inject_attacks,
1622 "attack_types": s.attack_types,
1623 "description": s.description,
1624 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1625 "is_get_or_head": is_get_or_head,
1626 "has_body": has_body,
1627 "body": processed_body.value,
1628 "body_is_dynamic": body_is_dynamic,
1629 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1630 })
1631 }).collect::<Vec<_>>(),
1632 })
1633 }).collect();
1634
1635 for flow_data in &flows_data {
1637 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1638 for step in steps {
1639 if let Some(placeholders_arr) =
1640 step.get("_placeholders").and_then(|p| p.as_array())
1641 {
1642 for p_str in placeholders_arr {
1643 if let Some(p_name) = p_str.as_str() {
1644 match p_name {
1646 "VU" => {
1647 all_placeholders.insert(DynamicPlaceholder::VU);
1648 }
1649 "Iteration" => {
1650 all_placeholders.insert(DynamicPlaceholder::Iteration);
1651 }
1652 "Timestamp" => {
1653 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1654 }
1655 "UUID" => {
1656 all_placeholders.insert(DynamicPlaceholder::UUID);
1657 }
1658 "Random" => {
1659 all_placeholders.insert(DynamicPlaceholder::Random);
1660 }
1661 "Counter" => {
1662 all_placeholders.insert(DynamicPlaceholder::Counter);
1663 }
1664 "Date" => {
1665 all_placeholders.insert(DynamicPlaceholder::Date);
1666 }
1667 "VuIter" => {
1668 all_placeholders.insert(DynamicPlaceholder::VuIter);
1669 }
1670 _ => {}
1671 }
1672 }
1673 }
1674 }
1675 }
1676 }
1677 }
1678
1679 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1681 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1682
1683 let invalid_data_config = self.build_invalid_data_config();
1685 let error_injection_enabled = invalid_data_config.is_some();
1686 let error_rate = self.error_rate.unwrap_or(0.0);
1687 let error_types: Vec<String> = invalid_data_config
1688 .as_ref()
1689 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1690 .unwrap_or_default();
1691
1692 if error_injection_enabled {
1693 TerminalReporter::print_progress(&format!(
1694 "Error injection enabled ({}% rate)",
1695 (error_rate * 100.0) as u32
1696 ));
1697 }
1698
1699 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1701
1702 let data = serde_json::json!({
1703 "base_url": self.target,
1704 "flows": flows_data,
1705 "extract_fields": config.default_extract_fields,
1706 "duration_secs": duration_secs,
1707 "max_vus": self.vus,
1708 "auth_header": self.auth,
1709 "custom_headers": custom_headers,
1710 "skip_tls_verify": self.skip_tls_verify,
1711 "stages": stages.iter().map(|s| serde_json::json!({
1713 "duration": s.duration,
1714 "target": s.target,
1715 })).collect::<Vec<_>>(),
1716 "threshold_percentile": self.threshold_percentile,
1717 "threshold_ms": self.threshold_ms,
1718 "max_error_rate": self.max_error_rate,
1719 "headers": headers_json,
1720 "dynamic_imports": required_imports,
1721 "dynamic_globals": required_globals,
1722 "error_injection_enabled": error_injection_enabled,
1724 "error_rate": error_rate,
1725 "error_types": error_types,
1726 "security_testing_enabled": security_testing_enabled,
1728 "has_custom_headers": !custom_headers.is_empty(),
1729 });
1730
1731 let mut script = handlebars
1732 .render_template(template, &data)
1733 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1734
1735 if security_testing_enabled {
1737 script = self.generate_enhanced_script(&script)?;
1738 }
1739
1740 TerminalReporter::print_progress("Validating CRUD flow script...");
1742 let validation_errors = K6ScriptGenerator::validate_script(&script);
1743 if !validation_errors.is_empty() {
1744 TerminalReporter::print_error("CRUD flow script validation failed");
1745 for error in &validation_errors {
1746 eprintln!(" {}", error);
1747 }
1748 return Err(BenchError::Other(format!(
1749 "CRUD flow script validation failed with {} error(s)",
1750 validation_errors.len()
1751 )));
1752 }
1753
1754 TerminalReporter::print_success("CRUD flow script generated");
1755
1756 let script_path = if let Some(output) = &self.script_output {
1758 output.clone()
1759 } else {
1760 self.output.join("k6-crud-flow-script.js")
1761 };
1762
1763 if let Some(parent) = script_path.parent() {
1764 std::fs::create_dir_all(parent)?;
1765 }
1766 std::fs::write(&script_path, &script)?;
1767 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1768
1769 if self.generate_only {
1770 println!("\nScript generated successfully. Run it with:");
1771 println!(" k6 run {}", script_path.display());
1772 return Ok(());
1773 }
1774
1775 TerminalReporter::print_progress("Executing CRUD flow test...");
1777 let executor = K6Executor::new()?;
1778 std::fs::create_dir_all(&self.output)?;
1779
1780 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1781
1782 let duration_secs = Self::parse_duration(&self.duration)?;
1783 TerminalReporter::print_summary(&results, duration_secs);
1784
1785 Ok(())
1786 }
1787
1788 async fn execute_conformance_test(&self) -> Result<()> {
1790 use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
1791 use crate::conformance::report::ConformanceReport;
1792 use crate::conformance::spec::ConformanceFeature;
1793
1794 TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
1795
1796 let categories = self.conformance_categories.as_ref().map(|cats_str| {
1798 cats_str
1799 .split(',')
1800 .filter_map(|s| {
1801 let trimmed = s.trim();
1802 if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
1803 Some(canonical.to_string())
1804 } else {
1805 TerminalReporter::print_warning(&format!(
1806 "Unknown conformance category: '{}'. Valid categories: {}",
1807 trimmed,
1808 ConformanceFeature::cli_category_names()
1809 .iter()
1810 .map(|(cli, _)| *cli)
1811 .collect::<Vec<_>>()
1812 .join(", ")
1813 ));
1814 None
1815 }
1816 })
1817 .collect::<Vec<String>>()
1818 });
1819
1820 let config = ConformanceConfig {
1821 target_url: self.target.clone(),
1822 api_key: self.conformance_api_key.clone(),
1823 basic_auth: self.conformance_basic_auth.clone(),
1824 skip_tls_verify: self.skip_tls_verify,
1825 categories,
1826 };
1827
1828 let script = if !self.spec.is_empty() {
1830 TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
1832 let parser = SpecParser::from_file(&self.spec[0]).await?;
1833 let operations = parser.get_operations();
1834
1835 let annotated =
1836 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
1837 &operations,
1838 parser.spec(),
1839 );
1840 TerminalReporter::print_success(&format!(
1841 "Analyzed {} operations, found {} feature annotations",
1842 operations.len(),
1843 annotated.iter().map(|a| a.features.len()).sum::<usize>()
1844 ));
1845
1846 let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
1847 config, annotated,
1848 );
1849 gen.generate()?
1850 } else {
1851 let generator = ConformanceGenerator::new(config);
1853 generator.generate()?
1854 };
1855
1856 std::fs::create_dir_all(&self.output)?;
1858 let script_path = self.output.join("k6-conformance.js");
1859 std::fs::write(&script_path, &script)
1860 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))?;
1861 TerminalReporter::print_success(&format!(
1862 "Conformance script generated: {}",
1863 script_path.display()
1864 ));
1865
1866 if self.generate_only {
1868 println!("\nScript generated. Run with:");
1869 println!(" k6 run {}", script_path.display());
1870 return Ok(());
1871 }
1872
1873 if !K6Executor::is_k6_installed() {
1875 TerminalReporter::print_error("k6 is not installed");
1876 TerminalReporter::print_warning(
1877 "Install k6 from: https://k6.io/docs/get-started/installation/",
1878 );
1879 return Err(BenchError::K6NotFound);
1880 }
1881
1882 TerminalReporter::print_progress("Running conformance tests...");
1884 let executor = K6Executor::new()?;
1885 executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1886
1887 let report_path = self.output.join("conformance-report.json");
1889 if report_path.exists() {
1890 let report = ConformanceReport::from_file(&report_path)?;
1891 report.print_report();
1892
1893 if self.conformance_report_format == "sarif" {
1895 use crate::conformance::sarif::ConformanceSarifReport;
1896 ConformanceSarifReport::write(&report, &self.target, &self.conformance_report)?;
1897 TerminalReporter::print_success(&format!(
1898 "SARIF report saved to: {}",
1899 self.conformance_report.display()
1900 ));
1901 } else if self.conformance_report != report_path {
1902 std::fs::copy(&report_path, &self.conformance_report)?;
1904 TerminalReporter::print_success(&format!(
1905 "Report saved to: {}",
1906 self.conformance_report.display()
1907 ));
1908 }
1909 } else {
1910 TerminalReporter::print_warning(
1911 "Conformance report not generated (k6 handleSummary may not have run)",
1912 );
1913 }
1914
1915 Ok(())
1916 }
1917
1918 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
1920 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
1921
1922 let custom_headers = self.parse_headers()?;
1924
1925 let mut config = OwaspApiConfig::new()
1927 .with_auth_header(&self.owasp_auth_header)
1928 .with_verbose(self.verbose)
1929 .with_insecure(self.skip_tls_verify)
1930 .with_concurrency(self.vus as usize)
1931 .with_iterations(self.owasp_iterations as usize)
1932 .with_base_path(self.base_path.clone())
1933 .with_custom_headers(custom_headers);
1934
1935 if let Some(ref token) = self.owasp_auth_token {
1937 config = config.with_valid_auth_token(token);
1938 }
1939
1940 if let Some(ref cats_str) = self.owasp_categories {
1942 let categories: Vec<OwaspCategory> = cats_str
1943 .split(',')
1944 .filter_map(|s| {
1945 let trimmed = s.trim();
1946 match trimmed.parse::<OwaspCategory>() {
1947 Ok(cat) => Some(cat),
1948 Err(e) => {
1949 TerminalReporter::print_warning(&e);
1950 None
1951 }
1952 }
1953 })
1954 .collect();
1955
1956 if !categories.is_empty() {
1957 config = config.with_categories(categories);
1958 }
1959 }
1960
1961 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
1963 config.admin_paths_file = Some(admin_paths_file.clone());
1964 if let Err(e) = config.load_admin_paths() {
1965 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
1966 }
1967 }
1968
1969 if let Some(ref id_fields_str) = self.owasp_id_fields {
1971 let id_fields: Vec<String> = id_fields_str
1972 .split(',')
1973 .map(|s| s.trim().to_string())
1974 .filter(|s| !s.is_empty())
1975 .collect();
1976 if !id_fields.is_empty() {
1977 config = config.with_id_fields(id_fields);
1978 }
1979 }
1980
1981 if let Some(ref report_path) = self.owasp_report {
1983 config = config.with_report_path(report_path);
1984 }
1985 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
1986 config = config.with_report_format(format);
1987 }
1988
1989 let categories = config.categories_to_test();
1991 TerminalReporter::print_success(&format!(
1992 "Testing {} OWASP categories: {}",
1993 categories.len(),
1994 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
1995 ));
1996
1997 if config.valid_auth_token.is_some() {
1998 TerminalReporter::print_progress("Using provided auth token for baseline requests");
1999 }
2000
2001 TerminalReporter::print_progress("Generating OWASP security test script...");
2003 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
2004
2005 let script = generator.generate()?;
2007 TerminalReporter::print_success("OWASP security test script generated");
2008
2009 let script_path = if let Some(output) = &self.script_output {
2011 output.clone()
2012 } else {
2013 self.output.join("k6-owasp-security-test.js")
2014 };
2015
2016 if let Some(parent) = script_path.parent() {
2017 std::fs::create_dir_all(parent)?;
2018 }
2019 std::fs::write(&script_path, &script)?;
2020 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2021
2022 if self.generate_only {
2024 println!("\nOWASP security test script generated. Run it with:");
2025 println!(" k6 run {}", script_path.display());
2026 return Ok(());
2027 }
2028
2029 TerminalReporter::print_progress("Executing OWASP security tests...");
2031 let executor = K6Executor::new()?;
2032 std::fs::create_dir_all(&self.output)?;
2033
2034 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2035
2036 let duration_secs = Self::parse_duration(&self.duration)?;
2037 TerminalReporter::print_summary(&results, duration_secs);
2038
2039 println!("\nOWASP security test results saved to: {}", self.output.display());
2040
2041 Ok(())
2042 }
2043}
2044
2045#[cfg(test)]
2046mod tests {
2047 use super::*;
2048
2049 #[test]
2050 fn test_parse_duration() {
2051 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
2052 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
2053 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
2054 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
2055 }
2056
2057 #[test]
2058 fn test_parse_duration_invalid() {
2059 assert!(BenchCommand::parse_duration("invalid").is_err());
2060 assert!(BenchCommand::parse_duration("30x").is_err());
2061 }
2062
2063 #[test]
2064 fn test_parse_headers() {
2065 let cmd = BenchCommand {
2066 spec: vec![PathBuf::from("test.yaml")],
2067 spec_dir: None,
2068 merge_conflicts: "error".to_string(),
2069 spec_mode: "merge".to_string(),
2070 dependency_config: None,
2071 target: "http://localhost".to_string(),
2072 base_path: None,
2073 duration: "1m".to_string(),
2074 vus: 10,
2075 scenario: "ramp-up".to_string(),
2076 operations: None,
2077 exclude_operations: None,
2078 auth: None,
2079 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
2080 output: PathBuf::from("output"),
2081 generate_only: false,
2082 script_output: None,
2083 threshold_percentile: "p(95)".to_string(),
2084 threshold_ms: 500,
2085 max_error_rate: 0.05,
2086 verbose: false,
2087 skip_tls_verify: false,
2088 targets_file: None,
2089 max_concurrency: None,
2090 results_format: "both".to_string(),
2091 params_file: None,
2092 crud_flow: false,
2093 flow_config: None,
2094 extract_fields: None,
2095 parallel_create: None,
2096 data_file: None,
2097 data_distribution: "unique-per-vu".to_string(),
2098 data_mappings: None,
2099 per_uri_control: false,
2100 error_rate: None,
2101 error_types: None,
2102 security_test: false,
2103 security_payloads: None,
2104 security_categories: None,
2105 security_target_fields: None,
2106 wafbench_dir: None,
2107 wafbench_cycle_all: false,
2108 owasp_api_top10: false,
2109 owasp_categories: None,
2110 owasp_auth_header: "Authorization".to_string(),
2111 owasp_auth_token: None,
2112 owasp_admin_paths: None,
2113 owasp_id_fields: None,
2114 owasp_report: None,
2115 owasp_report_format: "json".to_string(),
2116 owasp_iterations: 1,
2117 conformance: false,
2118 conformance_api_key: None,
2119 conformance_basic_auth: None,
2120 conformance_report: PathBuf::from("conformance-report.json"),
2121 conformance_categories: None,
2122 conformance_report_format: "json".to_string(),
2123 };
2124
2125 let headers = cmd.parse_headers().unwrap();
2126 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
2127 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
2128 }
2129
2130 #[test]
2131 fn test_get_spec_display_name() {
2132 let cmd = BenchCommand {
2133 spec: vec![PathBuf::from("test.yaml")],
2134 spec_dir: None,
2135 merge_conflicts: "error".to_string(),
2136 spec_mode: "merge".to_string(),
2137 dependency_config: None,
2138 target: "http://localhost".to_string(),
2139 base_path: None,
2140 duration: "1m".to_string(),
2141 vus: 10,
2142 scenario: "ramp-up".to_string(),
2143 operations: None,
2144 exclude_operations: None,
2145 auth: None,
2146 headers: None,
2147 output: PathBuf::from("output"),
2148 generate_only: false,
2149 script_output: None,
2150 threshold_percentile: "p(95)".to_string(),
2151 threshold_ms: 500,
2152 max_error_rate: 0.05,
2153 verbose: false,
2154 skip_tls_verify: false,
2155 targets_file: None,
2156 max_concurrency: None,
2157 results_format: "both".to_string(),
2158 params_file: None,
2159 crud_flow: false,
2160 flow_config: None,
2161 extract_fields: None,
2162 parallel_create: None,
2163 data_file: None,
2164 data_distribution: "unique-per-vu".to_string(),
2165 data_mappings: None,
2166 per_uri_control: false,
2167 error_rate: None,
2168 error_types: None,
2169 security_test: false,
2170 security_payloads: None,
2171 security_categories: None,
2172 security_target_fields: None,
2173 wafbench_dir: None,
2174 wafbench_cycle_all: false,
2175 owasp_api_top10: false,
2176 owasp_categories: None,
2177 owasp_auth_header: "Authorization".to_string(),
2178 owasp_auth_token: None,
2179 owasp_admin_paths: None,
2180 owasp_id_fields: None,
2181 owasp_report: None,
2182 owasp_report_format: "json".to_string(),
2183 owasp_iterations: 1,
2184 conformance: false,
2185 conformance_api_key: None,
2186 conformance_basic_auth: None,
2187 conformance_report: PathBuf::from("conformance-report.json"),
2188 conformance_categories: None,
2189 conformance_report_format: "json".to_string(),
2190 };
2191
2192 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
2193
2194 let cmd_multi = BenchCommand {
2196 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
2197 spec_dir: None,
2198 merge_conflicts: "error".to_string(),
2199 spec_mode: "merge".to_string(),
2200 dependency_config: None,
2201 target: "http://localhost".to_string(),
2202 base_path: None,
2203 duration: "1m".to_string(),
2204 vus: 10,
2205 scenario: "ramp-up".to_string(),
2206 operations: None,
2207 exclude_operations: None,
2208 auth: None,
2209 headers: None,
2210 output: PathBuf::from("output"),
2211 generate_only: false,
2212 script_output: None,
2213 threshold_percentile: "p(95)".to_string(),
2214 threshold_ms: 500,
2215 max_error_rate: 0.05,
2216 verbose: false,
2217 skip_tls_verify: false,
2218 targets_file: None,
2219 max_concurrency: None,
2220 results_format: "both".to_string(),
2221 params_file: None,
2222 crud_flow: false,
2223 flow_config: None,
2224 extract_fields: None,
2225 parallel_create: None,
2226 data_file: None,
2227 data_distribution: "unique-per-vu".to_string(),
2228 data_mappings: None,
2229 per_uri_control: false,
2230 error_rate: None,
2231 error_types: None,
2232 security_test: false,
2233 security_payloads: None,
2234 security_categories: None,
2235 security_target_fields: None,
2236 wafbench_dir: None,
2237 wafbench_cycle_all: false,
2238 owasp_api_top10: false,
2239 owasp_categories: None,
2240 owasp_auth_header: "Authorization".to_string(),
2241 owasp_auth_token: None,
2242 owasp_admin_paths: None,
2243 owasp_id_fields: None,
2244 owasp_report: None,
2245 owasp_report_format: "json".to_string(),
2246 owasp_iterations: 1,
2247 conformance: false,
2248 conformance_api_key: None,
2249 conformance_basic_auth: None,
2250 conformance_report: PathBuf::from("conformance-report.json"),
2251 conformance_categories: None,
2252 conformance_report_format: "json".to_string(),
2253 };
2254
2255 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2256 }
2257}