Skip to main content

mockforge_bench/
command.rs

1//! Bench command implementation
2
3use crate::crud_flow::{CrudFlowConfig, CrudFlowDetector};
4use crate::data_driven::{DataDistribution, DataDrivenConfig, DataDrivenGenerator, DataMapping};
5use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
6use crate::error::{BenchError, Result};
7use crate::executor::K6Executor;
8use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator};
9use crate::k6_gen::{K6Config, K6ScriptGenerator};
10use crate::mock_integration::{
11    MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
12};
13use crate::owasp_api::{OwaspApiConfig, OwaspApiGenerator, OwaspCategory, ReportFormat};
14use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
15use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
16use crate::param_overrides::ParameterOverrides;
17use crate::reporter::TerminalReporter;
18use crate::request_gen::RequestGenerator;
19use crate::scenarios::LoadScenario;
20use crate::security_payloads::{
21    SecurityCategory, SecurityPayload, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
22};
23use crate::spec_dependencies::{
24    topological_sort, DependencyDetector, ExtractedValues, SpecDependencyConfig,
25};
26use crate::spec_parser::SpecParser;
27use crate::target_parser::parse_targets_file;
28use crate::wafbench::WafBenchLoader;
29use mockforge_openapi::multi_spec::{
30    load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36
37/// Parse a list of `Key:Value` header strings into a `HashMap`.
38///
39/// Each element is one header in `Key:Value` form, supplied via a repeated
40/// `--headers` flag (`--headers "A:1" --headers "B:2"`). One header per flag
41/// means header VALUES may freely contain commas (#761) -- e.g. a Cookie value
42/// like `expires=Thu, 01 Jan 2099` is preserved intact, because we no longer
43/// split on commas. Empty elements are skipped so a stray flag is harmless.
44pub fn parse_header_string(inputs: &[String]) -> Result<HashMap<String, String>> {
45    let mut headers = HashMap::new();
46
47    for pair in inputs {
48        let pair = pair.trim();
49        if pair.is_empty() {
50            continue;
51        }
52        let parts: Vec<&str> = pair.splitn(2, ':').collect();
53        if parts.len() != 2 {
54            return Err(BenchError::Other(format!(
55                "Invalid header format: '{}'. Expected 'Key:Value'",
56                pair
57            )));
58        }
59        headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
60    }
61
62    Ok(headers)
63}
64
65/// Printed whenever `--conformance` is in play (#980).
66///
67/// Conformance is a functional correctness check: 1 VU, 1 iteration per
68/// endpoint, iteration count driven by the spec's endpoints. Every load-shaping
69/// flag is discarded.
70///
71/// Two things this has to say, because the earlier wording said neither and a
72/// user acted on the gap (#79 round 64 follow-up):
73///
74/// 1. `--rps` is ignored too. `target_rps` is never read on the conformance
75///    path, but the old message named only `--vus` and `-d`, so anyone tuning
76///    throughput got no signal.
77/// 2. `--conformance` REPLACES the load run, it does not run alongside it.
78///    `BenchCommand::execute` returns into the conformance path before the load
79///    path is reached, so a command carrying a full set of load flags produces
80///    `k6-conformance.js` and no `k6-script.js`, with no load traffic at all.
81///
82/// Shared by the single-target and multi-target conformance paths so the two
83/// cannot drift; `conformance_advisory_names_every_discarded_flag` guards it.
84const CONFORMANCE_REPLACES_LOAD_ADVISORY: &str =
85    "Conformance mode REPLACES the load run: 1 VU, 1 iteration per endpoint. \
86     --vus, --rps and -d are ignored. Run bench a second time without \
87     --conformance if you also want a load test.";
88
89/// Bench command configuration
90pub struct BenchCommand {
91    /// OpenAPI spec file(s) - can specify multiple
92    pub spec: Vec<PathBuf>,
93    /// Directory containing OpenAPI spec files (discovers .json, .yaml, .yml files)
94    pub spec_dir: Option<PathBuf>,
95    /// Conflict resolution strategy when merging multiple specs: "error" (default), "first", "last"
96    pub merge_conflicts: String,
97    /// Spec mode: "merge" (default) combines all specs, "sequential" runs them in order
98    pub spec_mode: String,
99    /// Dependency configuration file for cross-spec value passing (used with sequential mode)
100    pub dependency_config: Option<PathBuf>,
101    pub target: String,
102    /// API base path prefix (e.g., "/api" or "/v2/api")
103    /// If None, extracts from OpenAPI spec's servers URL
104    pub base_path: Option<String>,
105    pub duration: String,
106    pub vus: u32,
107    /// Target requests-per-second. When `Some(n)`, the generated k6 script
108    /// switches to `constant-arrival-rate` executor at `n` RPS with `vus`
109    /// pre-allocated. When `None`, uses the legacy `ramping-vus` executor
110    /// where RPS is implicit (VUs × 1 req/sec from the script's `sleep(1)`).
111    /// Issue #79 — Srikanth's round-3 reply.
112    pub target_rps: Option<u32>,
113    /// When true, every k6 request opens a new TCP/TLS connection
114    /// (`noConnectionReuse: true` and `--no-vu-connection-reuse`). Lets users
115    /// drive a high connections-per-second rate to exercise connection-limit
116    /// chaos and observe TCP-level fault injection. Issue #79.
117    pub no_keep_alive: bool,
118    pub scenario: String,
119    pub operations: Option<String>,
120    /// Exclude operations from testing (comma-separated)
121    ///
122    /// Supports "METHOD /path" or just "METHOD" to exclude all operations of that type.
123    pub exclude_operations: Option<String>,
124    pub auth: Option<String>,
125    /// Additional headers, one `Key:Value` per entry (repeated `--headers` flag).
126    /// One header per entry so values may contain commas (#761).
127    pub headers: Vec<String>,
128    pub output: PathBuf,
129    pub generate_only: bool,
130    pub script_output: Option<PathBuf>,
131    pub threshold_percentile: String,
132    pub threshold_ms: u64,
133    pub max_error_rate: f64,
134    /// Round 62 (#79) — emit the k6 `abortOnFail` memory safety valve. Default
135    /// true (round-60 behaviour). `--no-abort-on-error` sets this false so a
136    /// stress run against a high-rejection WAF/proxy runs its full duration
137    /// instead of aborting when the error rate crosses `abort_on_error_rate`.
138    pub abort_on_error: bool,
139    /// Round 62 (#79) — failure-rate threshold (0.0-1.0) for the abort valve.
140    /// Default 0.95. Tunable via `--abort-on-error-rate`; ignored when
141    /// `abort_on_error` is false.
142    pub abort_on_error_rate: f64,
143    pub verbose: bool,
144    pub skip_tls_verify: bool,
145    /// When true, set `Transfer-Encoding: chunked` on every k6 request body so
146    /// the server experiences chunked-encoded traffic. See
147    /// `K6ScriptTemplateData::chunked_request_bodies` for caveats — k6's Go
148    /// transport may still send Content-Length in some cases.
149    pub chunked_request_bodies: bool,
150    /// Optional file containing multiple targets
151    pub targets_file: Option<PathBuf>,
152    /// Maximum number of parallel executions (for multi-target mode)
153    pub max_concurrency: Option<u32>,
154    /// Results format: "per-target", "aggregated", or "both"
155    pub results_format: String,
156    /// Optional file containing parameter value overrides (JSON or YAML)
157    ///
158    /// Allows users to provide custom values for path parameters, query parameters,
159    /// headers, and request bodies instead of auto-generated placeholder values.
160    pub params_file: Option<PathBuf>,
161
162    // === CRUD Flow Options ===
163    /// Enable CRUD flow mode
164    pub crud_flow: bool,
165    /// Custom CRUD flow configuration file
166    pub flow_config: Option<PathBuf>,
167    /// Fields to extract from responses
168    pub extract_fields: Option<String>,
169
170    // === Parallel Execution Options ===
171    /// Number of resources to create in parallel
172    pub parallel_create: Option<u32>,
173
174    // === Data-Driven Testing Options ===
175    /// Test data file (CSV or JSON)
176    pub data_file: Option<PathBuf>,
177    /// Data distribution strategy
178    pub data_distribution: String,
179    /// Data column to field mappings
180    pub data_mappings: Option<String>,
181    /// Enable per-URI control mode (each row specifies method, uri, body, etc.)
182    pub per_uri_control: bool,
183
184    // === Invalid Data Testing Options ===
185    /// Percentage of requests with invalid data
186    pub error_rate: Option<f64>,
187    /// Types of invalid data to generate
188    pub error_types: Option<String>,
189
190    // === Security Testing Options ===
191    /// Enable security testing
192    pub security_test: bool,
193    /// Custom security payloads file
194    pub security_payloads: Option<PathBuf>,
195    /// Security test categories
196    pub security_categories: Option<String>,
197    /// Fields to target for security injection
198    pub security_target_fields: Option<String>,
199
200    // === WAFBench Integration ===
201    /// WAFBench test directory or glob pattern for loading CRS attack patterns
202    pub wafbench_dir: Option<String>,
203    /// Cycle through ALL WAFBench payloads instead of random sampling
204    pub wafbench_cycle_all: bool,
205    /// Send traffic cases exactly as written instead of extracting an attack
206    /// payload from them (#994). See the CLI flag docs for why this exists.
207    pub wafbench_verbatim: bool,
208
209    // === OpenAPI 3.0.0 Conformance Testing ===
210    /// Enable conformance testing mode
211    pub conformance: bool,
212    /// API key for conformance security tests
213    pub conformance_api_key: Option<String>,
214    /// Basic auth credentials for conformance security tests (user:pass)
215    pub conformance_basic_auth: Option<String>,
216    /// Conformance report output file
217    pub conformance_report: PathBuf,
218    /// Conformance categories to test (comma-separated, e.g. "parameters,security")
219    pub conformance_categories: Option<String>,
220    /// Conformance report format: "json" or "sarif"
221    pub conformance_report_format: String,
222    /// Custom headers to inject into every conformance request (for authentication).
223    /// Each entry is "Header-Name: value" format.
224    pub conformance_headers: Vec<String>,
225    /// When true, test ALL operations for method/response/body categories
226    /// instead of just one representative per feature check.
227    pub conformance_all_operations: bool,
228    /// Optional YAML file with custom conformance checks
229    pub conformance_custom: Option<PathBuf>,
230    /// Delay in milliseconds between consecutive conformance requests.
231    /// Useful when testing against rate-limited APIs.
232    pub conformance_delay_ms: u64,
233    /// Use k6 for conformance test execution instead of the native Rust executor
234    pub use_k6: bool,
235    /// Regex filter for custom conformance checks — only checks whose name or
236    /// path matches the pattern are included. Example: "wafcrs|ssl" to test
237    /// only checks with "wafcrs" or "ssl" in the name/path.
238    pub conformance_custom_filter: Option<String>,
239    /// When true, export all request/response pairs to
240    /// `conformance-requests.json` in the output directory.
241    pub export_requests: bool,
242    /// When true, validate each request against the OpenAPI spec and report
243    /// violations to `conformance-request-violations.json`.
244    pub validate_requests: bool,
245    /// Issue #79 round 13 (4) — when true, replace the standard
246    /// conformance run with a positive + per-category negative
247    /// self-test driver. Verifies that the server actually rejects
248    /// the negatives with 4xx (i.e. its validator is wired correctly).
249    /// Useful to confirm the round-13 (3) validator-bypass fix took
250    /// effect against the user's spec.
251    pub conformance_self_test: bool,
252    /// Round 23 (c-iii) — when true, capture every self-test probe's
253    /// full request/response to `conformance-self-test-requests.jsonl`.
254    /// No effect outside `--conformance-self-test`.
255    pub conformance_self_test_capture: bool,
256    /// Round 25 (21.3 / a2 / a3) — when true, validate every probe's
257    /// response body against the spec's response schema for the actual
258    /// status returned. Requires `--conformance-self-test-capture`
259    /// because validation reads the captured body. No effect outside
260    /// `--conformance-self-test`.
261    pub validate_response_schemas: bool,
262    /// Round 47 (#79) — repeat the full self-test probe matrix this
263    /// many times in sequence. Defaults to 1. Combines with
264    /// `--conformance-delay` to keep a steady probe rate for network-
265    /// failure simulation.
266    pub conformance_self_test_iterations: u32,
267    /// Round 47 (#79) — alternative to iterations: keep firing the
268    /// probe matrix until this duration elapses. Overrides
269    /// `conformance_self_test_iterations` when present (iterations
270    /// becomes the floor — at least one matrix is always fired).
271    pub conformance_self_test_duration: Option<String>,
272
273    /// Round 18.5 — local source IPs to bind self-test requests to.
274    /// Each entry must be a valid `IpAddr` and already assigned to
275    /// an interface on the host. Operations round-robin through the
276    /// pool. Empty → one default client.
277    pub source_ips: Vec<String>,
278    /// Round 18.5 — fake source IPs to advertise via forwarded-IP
279    /// headers (rotated per operation). Used for GEODB testing
280    /// where the destination reads the IP from a header.
281    pub geo_source_ips: Vec<String>,
282    /// Round 18.5 — which forwarded-IP header(s) to populate when
283    /// `geo_source_ips` is non-empty. Empty → default 3-header set
284    /// (X-Forwarded-For, True-Client-IP, CF-Connecting-IP).
285    pub geo_source_headers: Vec<String>,
286
287    /// Round 21.1 — cap the HTML conformance report's missed-negative
288    /// drill-down at N rows. `Some(0)` means no cap; `None` keeps the
289    /// default of 200. The JSON report always carries the full set
290    /// regardless of this knob — it only controls what the HTML drill
291    /// view shows so a 50 000-violation run doesn't produce a 5 MB
292    /// browser-choking HTML file by default.
293    pub report_missed_cap: Option<u32>,
294
295    /// Round 57 (#79) — when true, run k6 load with
296    /// `K6_DISCARD_RESPONSE_BODIES=true` so it does not buffer response bodies
297    /// in memory. Exposes the r56 env var as a first-class flag for scale /
298    /// stress runs. Only affects the plain (status-only) load paths; the
299    /// conformance / self-test / CRUD-extraction paths that read the body
300    /// ignore it. Multi-target load already discards by default (r56).
301    pub discard_response_bodies: bool,
302
303    /// Round 61 (#79) — k6 DNS resolution policy (`preferIPv6`, `onlyIPv6`,
304    /// `preferIPv4`, `onlyIPv4`, `any`). `None` → k6 default (`preferIPv4`).
305    /// Passed to k6 as `--dns "policy=<value>"`. Lets a GEODB IPv6 test pin
306    /// hostname targets to their AAAA record while keeping the hostname on the
307    /// wire (Srikanth's WAF routes by Host/SNI, so the target must be a name).
308    pub dns_policy: Option<String>,
309
310    // === OWASP API Security Top 10 Testing ===
311    /// Enable OWASP API Security Top 10 testing mode
312    pub owasp_api_top10: bool,
313    /// OWASP API categories to test (comma-separated)
314    pub owasp_categories: Option<String>,
315    /// Authorization header name for OWASP auth tests
316    pub owasp_auth_header: String,
317    /// Valid authorization token for OWASP baseline requests
318    pub owasp_auth_token: Option<String>,
319    /// File containing admin/privileged paths to test
320    pub owasp_admin_paths: Option<PathBuf>,
321    /// Fields containing resource IDs for BOLA testing
322    pub owasp_id_fields: Option<String>,
323    /// OWASP report output file
324    pub owasp_report: Option<PathBuf>,
325    /// OWASP report format (json, sarif)
326    pub owasp_report_format: String,
327    /// Number of iterations per VU for OWASP tests (default: 1)
328    pub owasp_iterations: u32,
329}
330
331/// Round 18.5 / 19 — parse a list of CLI IP strings. Each entry may be:
332/// - a single IPv4/IPv6 (`10.0.0.5` / `2001:db8::1`)
333/// - a comma-separated list (`10.0.0.5,10.0.0.6,2001:db8::1`)
334/// - a CIDR range (`10.0.0.0/29` expands to 8 hosts;
335///   `2001:db8::/126` expands to 4 IPv6 hosts)
336///
337/// CIDR ranges are capped at `MAX_CIDR_EXPANSION` (256) host
338/// addresses to avoid OOM'ing on `/8` typos. The cap is generous
339/// for GEODB testing (you want 20–100 IPs, not 10M) and the warning
340/// names the cap so it's debuggable.
341///
342/// Malformed entries log a warning and are dropped; the bench
343/// continues with whatever resolved cleanly.
344fn parse_ip_list(raw: &[String], flag_name: &str) -> Vec<std::net::IpAddr> {
345    use std::net::IpAddr;
346    const MAX_CIDR_EXPANSION: usize = 256;
347    let mut out = Vec::new();
348    for entry in raw {
349        for piece in entry.split(',') {
350            let s = piece.trim();
351            if s.is_empty() {
352                continue;
353            }
354            // CIDR form: `ip/prefix`
355            if let Some((addr_part, prefix_part)) = s.split_once('/') {
356                let prefix: u32 = match prefix_part.parse() {
357                    Ok(p) => p,
358                    Err(e) => {
359                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad CIDR prefix: {e}");
360                        continue;
361                    }
362                };
363                let net_addr: IpAddr = match addr_part.parse() {
364                    Ok(a) => a,
365                    Err(e) => {
366                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad CIDR address: {e}");
367                        continue;
368                    }
369                };
370                expand_cidr(net_addr, prefix, MAX_CIDR_EXPANSION, flag_name, s, &mut out);
371                continue;
372            }
373            // Round 22.4 — range form: `start-end` (Srikanth (h)).
374            // Lets users specify non-power-of-2 ranges without
375            // finding a clean prefix. IPv4 only for now (the most
376            // common case); IPv6 ranges with `:` collide with the
377            // address literal so they'd need a different separator.
378            if let Some((start_str, end_str)) = s.split_once('-') {
379                let start_s = start_str.trim();
380                let end_s = end_str.trim();
381                // Reject ambiguous IPv6 ranges (contain `:`) so we
382                // don't accidentally parse `2001:db8::1-2001:db8::5`
383                // as a half address.
384                if start_s.contains(':') || end_s.contains(':') {
385                    tracing::warn!(target: "mockforge::bench", "--{flag_name} '{s}': IPv6 range syntax not supported (use CIDR like 2001:db8::/126 instead)");
386                    continue;
387                }
388                let start: IpAddr = match start_s.parse() {
389                    Ok(a) => a,
390                    Err(e) => {
391                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad range start: {e}");
392                        continue;
393                    }
394                };
395                let end: IpAddr = match end_s.parse() {
396                    Ok(a) => a,
397                    Err(e) => {
398                        tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{s}': bad range end: {e}");
399                        continue;
400                    }
401                };
402                expand_range(start, end, MAX_CIDR_EXPANSION, flag_name, s, &mut out);
403                continue;
404            }
405            // Plain IP form
406            match s.parse::<IpAddr>() {
407                Ok(ip) => out.push(ip),
408                Err(e) => {
409                    tracing::warn!(target: "mockforge::bench", "ignoring malformed --{flag_name} value '{s}': {e}");
410                }
411            }
412        }
413    }
414    out
415}
416
417/// Round 22.4 — expand an inclusive `start-end` IPv4 range to host
418/// addresses, capped at `cap`. Returns silently on a backwards or
419/// mixed-family range with a warning.
420fn expand_range(
421    start: std::net::IpAddr,
422    end: std::net::IpAddr,
423    cap: usize,
424    flag_name: &str,
425    raw: &str,
426    out: &mut Vec<std::net::IpAddr>,
427) {
428    use std::net::{IpAddr, Ipv4Addr};
429    let (start_v4, end_v4) = match (start, end) {
430        (IpAddr::V4(a), IpAddr::V4(b)) => (a, b),
431        _ => {
432            tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range start/end must both be IPv4");
433            return;
434        }
435    };
436    let start_u32 = u32::from(start_v4);
437    let end_u32 = u32::from(end_v4);
438    if end_u32 < start_u32 {
439        tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range end {end_v4} is before start {start_v4}");
440        return;
441    }
442    let total = (end_u32 - start_u32).saturating_add(1) as usize;
443    let take = total.min(cap);
444    if total > cap {
445        tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': range has {total} addresses, capping at {cap}");
446    }
447    for i in 0..take as u32 {
448        out.push(IpAddr::V4(Ipv4Addr::from(start_u32 + i)));
449    }
450}
451
452/// Expand a CIDR (IPv4 or IPv6) into individual host IPs, appending
453/// to `out`. Capped at `cap` to prevent runaway expansion on a `/8`
454/// typo. When the cap kicks in we log a warning and skip the rest.
455fn expand_cidr(
456    net: std::net::IpAddr,
457    prefix: u32,
458    cap: usize,
459    flag_name: &str,
460    raw: &str,
461    out: &mut Vec<std::net::IpAddr>,
462) {
463    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
464    match net {
465        IpAddr::V4(ipv4) => {
466            if prefix > 32 {
467                tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{raw}': IPv4 prefix must be <= 32");
468                return;
469            }
470            let total: u64 = 1u64 << (32 - prefix);
471            let take = total.min(cap as u64) as u32;
472            if total > cap as u64 {
473                tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': CIDR has {total} addresses, capping at {cap}");
474            }
475            let mask: u32 = if prefix == 0 {
476                0
477            } else {
478                !0u32 << (32 - prefix)
479            };
480            let net_u32 = u32::from(ipv4) & mask;
481            for i in 0..take {
482                out.push(IpAddr::V4(Ipv4Addr::from(net_u32.wrapping_add(i))));
483            }
484        }
485        IpAddr::V6(ipv6) => {
486            if prefix > 128 {
487                tracing::warn!(target: "mockforge::bench", "ignoring --{flag_name} '{raw}': IPv6 prefix must be <= 128");
488                return;
489            }
490            // Total addresses = 2^(128-prefix). Cap at u128::MAX
491            // conceptually but since `cap` is small (256) we just
492            // iterate up to cap.
493            let mask: u128 = if prefix == 0 {
494                0
495            } else {
496                !0u128 << (128 - prefix)
497            };
498            let net_u128 = u128::from(ipv6) & mask;
499            let remaining_bits = 128 - prefix;
500            // Compute total carefully — for prefix=0 this is 2^128
501            // which overflows; we just clamp via take.
502            let total_capped = if remaining_bits >= 64 {
503                cap as u128
504            } else {
505                (1u128 << remaining_bits).min(cap as u128)
506            };
507            if remaining_bits < 128 && (1u128 << remaining_bits) > cap as u128 {
508                tracing::warn!(target: "mockforge::bench", "--{flag_name} '{raw}': IPv6 CIDR exceeds {cap} addresses, capping");
509            }
510            for i in 0..total_capped {
511                out.push(IpAddr::V6(Ipv6Addr::from(net_u128.wrapping_add(i))));
512            }
513        }
514    }
515}
516
517impl BenchCommand {
518    /// Whether the k6 script should carry the security payload-injection layer.
519    ///
520    /// This is the ONLY place the answer is computed. It used to be spelled out
521    /// at four separate call sites, which is the #79 drift shape: the template
522    /// gates `{{#if security_testing_enabled}}` on it, so any site that
523    /// disagreed with another produced either dead code or a call to an
524    /// undefined function.
525    ///
526    /// #997: `--wafbench-verbatim` turns it OFF. In verbatim mode
527    /// `--wafbench-dir` supplies the REQUESTS, not a payload pool, so treating
528    /// it as a pool made the injector append `&test=<payload>` to requests the
529    /// user asked to be sent exactly as written. That corrupted the very cases
530    /// under test and, worse, appended attack payloads to `expected: 200` cases,
531    /// so a correctly-behaving WAF would block them and the run would report a
532    /// failure the user did not write.
533    pub fn security_testing_enabled(&self) -> bool {
534        if self.wafbench_verbatim {
535            return false;
536        }
537        self.security_test || self.wafbench_dir.is_some()
538    }
539
540    /// Load and merge specs from --spec files and --spec-dir
541    pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
542        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
543
544        // Load specs from --spec flags
545        if !self.spec.is_empty() {
546            let specs = load_specs_from_files(self.spec.clone())
547                .await
548                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
549            all_specs.extend(specs);
550        }
551
552        // Load specs from --spec-dir if provided
553        if let Some(spec_dir) = &self.spec_dir {
554            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
555                BenchError::Other(format!("Failed to load specs from directory: {}", e))
556            })?;
557            all_specs.extend(dir_specs);
558        }
559
560        if all_specs.is_empty() {
561            return Err(BenchError::Other(
562                "No spec files provided. Use --spec or --spec-dir.".to_string(),
563            ));
564        }
565
566        // If only one spec, return it directly (extract just the OpenApiSpec)
567        if all_specs.len() == 1 {
568            // Safe to unwrap because we just checked len() == 1
569            return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
570        }
571
572        // Merge multiple specs
573        let conflict_strategy = match self.merge_conflicts.as_str() {
574            "first" => ConflictStrategy::First,
575            "last" => ConflictStrategy::Last,
576            _ => ConflictStrategy::Error,
577        };
578
579        merge_specs(all_specs, conflict_strategy)
580            .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
581    }
582
583    /// Get a display name for the spec(s)
584    fn get_spec_display_name(&self) -> String {
585        if self.spec.len() == 1 {
586            self.spec[0].to_string_lossy().to_string()
587        } else if !self.spec.is_empty() {
588            format!("{} spec files", self.spec.len())
589        } else if let Some(dir) = &self.spec_dir {
590            format!("specs from {}", dir.display())
591        } else {
592            "no specs".to_string()
593        }
594    }
595
596    /// Round 29 — capacity advisory. Counts the targets-file entries
597    /// (or 1 for single-target), multiplies by VUs and configured RPS,
598    /// and prints a warning when the product exceeds rule-of-thumb
599    /// limits a single client can handle. The detailed sizing table
600    /// lives in `book/src/reference/bench-capacity-sizing.md`; this
601    /// just nudges the user toward it before they hang their VM.
602    fn advise_capacity(&self) {
603        let target_count = self
604            .targets_file
605            .as_ref()
606            .and_then(|p| std::fs::read_to_string(p).ok())
607            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
608            .and_then(|v| v.as_array().map(|a| a.len()))
609            .unwrap_or(1);
610        let vus = self.vus.max(1);
611        let rps_total = self.target_rps.unwrap_or(0) as usize * target_count.max(1);
612        // 50 targets × 5 VUs = 250 is roughly where Srikanth's 15GB
613        // client started hanging on a 10 MB spec. Surface a warning
614        // well before that.
615        let load_product = target_count * vus as usize;
616        if load_product >= 150 {
617            let est_ram_gb =
618                (vus as usize * 50) / 1024 + (target_count * 10 * 2) / 1024 + target_count / 2;
619            let est_cores = ((vus as usize) / 50).max(2);
620            TerminalReporter::print_warning(&format!(
621                "Capacity advisory: targets={target_count}, VUs={vus}, RPS-total≈{rps_total}. \
622                 Single-client estimate: ~{est_cores} CPU cores, ~{est_ram_gb} GB RAM. \
623                 If your machine is below that, expect OOM hangs partway through the run. \
624                 See https://docs.mockforge.dev/reference/bench-capacity-sizing.html \
625                 for the sizing table and sharding guide."
626            ));
627        }
628    }
629
630    /// Execute the bench command
631    pub async fn execute(&self) -> Result<()> {
632        // Round 23 — Srikanth flagged that k6 _does_ support per-VU source
633        // IPs via `--local-ips` (the round-22 warning that said otherwise
634        // was wrong). The k6 path now forwards `--source-ip` straight to
635        // `k6 run --local-ips`, so the only case worth flagging is the
636        // self-test+k6 combo: self-test returns before k6 ever launches,
637        // so `--use-k6` on that command is a no-op.
638        if self.conformance_self_test && self.use_k6 {
639            TerminalReporter::print_warning(
640                "--use-k6 has no effect with --conformance-self-test: the self-test driver runs and returns before k6 is invoked. Drop one or the other depending on whether you want the spec-driven self-test or a k6 bench run.",
641            );
642        }
643
644        // Round 29 — Srikanth's "5 VUs against 50 targets hung the VM"
645        // report. Print an up-front capacity advisory so the user knows
646        // BEFORE the run starts that their config exceeds what one
647        // client can comfortably handle. Pure heuristic; rules of
648        // thumb live in book/src/reference/bench-capacity-sizing.md.
649        self.advise_capacity();
650
651        // Check if we're in multi-target mode
652        if let Some(targets_file) = &self.targets_file {
653            // Round 48 (#79) — Srikanth on 0.3.192: --conformance-self-test
654            // with --targets-file silently ignored both the self-test
655            // driver AND the round-47 iterations/duration knobs because
656            // the dispatch above returned into `execute_multi_target_conformance`
657            // which runs the regular k6 conformance flow. Now route to
658            // a dedicated multi-target self-test path that runs the
659            // self-test driver against EACH target with the same
660            // iteration/duration loop the single-target path got in r47.
661            if self.conformance && self.conformance_self_test {
662                return self.execute_multi_target_self_test(targets_file).await;
663            }
664            if self.conformance {
665                return self.execute_multi_target_conformance(targets_file).await;
666            }
667            return self.execute_multi_target(targets_file).await;
668        }
669
670        // Check if we're in sequential spec mode (for dependency handling)
671        if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
672            return self.execute_sequential_specs().await;
673        }
674
675        // Single target mode (existing behavior)
676        // Print header
677        TerminalReporter::print_header(
678            &self.get_spec_display_name(),
679            &self.target,
680            0, // Will be updated later
681            &self.scenario,
682            Self::parse_duration(&self.duration)?,
683        );
684
685        // Validate k6 installation
686        if !K6Executor::is_k6_installed() {
687            TerminalReporter::print_error("k6 is not installed");
688            TerminalReporter::print_warning(
689                "Install k6 from: https://k6.io/docs/get-started/installation/",
690            );
691            return Err(BenchError::K6NotFound);
692        }
693
694        // Check for conformance testing mode (before spec loading — conformance doesn't need a user spec)
695        if self.conformance {
696            return self.execute_conformance_test().await;
697        }
698
699        // Load and parse spec(s).
700        //
701        // #997: verbatim mode derives every request from the traffic file, so a
702        // spec supplies nothing. Requiring one anyway forced users to pass an
703        // unrelated file just to satisfy the check. It stays OPTIONAL rather
704        // than ignored: a spec is still honoured for --base-path resolution.
705        let spec_supplied = !self.spec.is_empty() || self.spec_dir.is_some();
706        let merged_spec = if self.wafbench_verbatim && !spec_supplied {
707            tracing::info!(
708                target: "mockforge::bench",
709                "--wafbench-verbatim without --spec: sending only the traffic file's requests"
710            );
711            OpenApiSpec {
712                spec: Default::default(),
713                file_path: None,
714                raw_document: None,
715            }
716        } else {
717            TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
718            self.load_and_merge_specs().await?
719        };
720        let parser = SpecParser::from_spec(merged_spec);
721        if self.spec.len() > 1 || self.spec_dir.is_some() {
722            TerminalReporter::print_success(&format!(
723                "Loaded and merged {} specification(s)",
724                self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
725            ));
726        } else {
727            TerminalReporter::print_success("Specification loaded");
728        }
729
730        // Check for mock server integration
731        let mock_config = self.build_mock_config().await;
732        if mock_config.is_mock_server {
733            TerminalReporter::print_progress("Mock server integration enabled");
734        }
735
736        // Check for CRUD flow mode
737        if self.crud_flow {
738            return self.execute_crud_flow(&parser).await;
739        }
740
741        // Check for OWASP API Top 10 testing mode
742        if self.owasp_api_top10 {
743            return self.execute_owasp_test(&parser).await;
744        }
745
746        // Get operations
747        TerminalReporter::print_progress("Extracting API operations...");
748        let mut operations = if let Some(filter) = &self.operations {
749            parser.filter_operations(filter)?
750        } else {
751            parser.get_operations()
752        };
753
754        // Apply exclusions if provided
755        if let Some(exclude) = &self.exclude_operations {
756            let before_count = operations.len();
757            operations = parser.exclude_operations(operations, exclude)?;
758            let excluded_count = before_count - operations.len();
759            if excluded_count > 0 {
760                TerminalReporter::print_progress(&format!(
761                    "Excluded {} operations matching '{}'",
762                    excluded_count, exclude
763                ));
764            }
765        }
766
767        // #997: in verbatim mode the spec is optional, so an empty operation set
768        // is expected rather than an error — the traffic file supplies the
769        // requests. Erroring here would make --wafbench-verbatim unusable
770        // without an unrelated spec, which is the thing that check exists to
771        // prevent in every OTHER mode.
772        if operations.is_empty() && !self.wafbench_verbatim {
773            return Err(BenchError::Other("No operations found in spec".to_string()));
774        }
775
776        TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
777
778        // Load parameter overrides if provided
779        let param_overrides = if let Some(params_file) = &self.params_file {
780            TerminalReporter::print_progress("Loading parameter overrides...");
781            let overrides = ParameterOverrides::from_file(params_file)?;
782            TerminalReporter::print_success(&format!(
783                "Loaded parameter overrides ({} operation-specific, {} defaults)",
784                overrides.operations.len(),
785                if overrides.defaults.is_empty() { 0 } else { 1 }
786            ));
787            Some(overrides)
788        } else {
789            None
790        };
791
792        // Generate request templates
793        TerminalReporter::print_progress("Generating request templates...");
794        let templates: Vec<_> = operations
795            .iter()
796            .map(|op| {
797                let op_overrides = param_overrides.as_ref().map(|po| {
798                    po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
799                });
800                RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
801            })
802            .collect::<Result<Vec<_>>>()?;
803        TerminalReporter::print_success("Request templates generated");
804
805        // #994: verbatim mode replaces the spec-derived templates outright. The
806        // spec still loads (it supplies base path and is often required by other
807        // flags), but every request now comes from the traffic file, sent as
808        // written. Anything else would silently mix fuzzed spec endpoints into a
809        // run the user asked to be literal.
810        let templates = if self.wafbench_verbatim {
811            let verbatim = self.load_verbatim_templates()?;
812            if verbatim.is_empty() {
813                return Err(BenchError::Other(
814                    "--wafbench-verbatim was set but no traffic cases were loaded. Check \
815                     --wafbench-dir points at a file, directory or glob containing cases with \
816                     a `request.uri`."
817                        .to_string(),
818                ));
819            }
820            TerminalReporter::print_success(&format!(
821                "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
822                verbatim.len()
823            ));
824            verbatim
825        } else {
826            templates
827        };
828
829        // Parse headers
830        let custom_headers = self.parse_headers()?;
831
832        // Resolve base path (CLI option takes priority over spec's servers URL)
833        let base_path = self.resolve_base_path(&parser);
834        if let Some(ref bp) = base_path {
835            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
836        }
837
838        // Generate k6 script
839        TerminalReporter::print_progress("Generating k6 load test script...");
840        let scenario =
841            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
842
843        let security_testing_enabled = self.security_testing_enabled();
844
845        // Issue #79 round 6 follow-up — Srikanth reported k6 emitting
846        // "Insufficient VUs, reached 5 active VUs and cannot initialize more"
847        // when running `--rps 100 --vus 5`. With the `constant-arrival-rate`
848        // executor, k6 needs roughly `rps × avg_request_seconds` VUs to keep
849        // up; if `--vus` is too low it can't sustain the rate. Warn pre-flight
850        // so users know to bump `--vus` rather than chase the warning.
851        //
852        // Round 8 (#79): the static 100ms heuristic was wrong for fast targets
853        // (~2ms latency). Probe the actual target first to measure baseline
854        // latency, then derive a more accurate sizing recommendation. Fall
855        // back to the 100ms heuristic only when the probe can't reach the
856        // target (auth-gated endpoints, strict WAFs, etc).
857        //
858        // Round 9 (#79): factor in operation count. k6's constant-arrival-rate
859        // counts ITERATIONS, not requests — and every iteration runs all N
860        // operations sequentially, so required VUs scale with N. Srikanth's
861        // 12-op spec at --rps 100 with 15ms latency needs ~19 VUs, not 3.
862        let num_ops = operations.len() as u32;
863        if let Some(rps) = self.target_rps {
864            let probe =
865                crate::preflight::probe_target_latency(&self.target, 3, self.skip_tls_verify).await;
866
867            let (required_vus, basis) = match probe {
868                Some(p) => (
869                    p.required_vus(rps, num_ops),
870                    format!("avg {:.1}ms (measured)", p.avg_latency.as_secs_f64() * 1000.0),
871                ),
872                None => {
873                    // Static fallback: ~100ms heuristic × num_ops per iteration.
874                    let fallback = (rps as u64)
875                        .saturating_mul(num_ops.max(1) as u64)
876                        .div_ceil(10)
877                        .min(u32::MAX as u64) as u32;
878                    (fallback, "~100ms (default — probe failed)".to_string())
879                }
880            };
881
882            if self.vus < required_vus {
883                // Round 10 (#79): Srikanth's 11422-op spec at --rps 100 produced
884                // a recommendation of ~10,740 VUs, which is absurd in practice.
885                // When the recommendation goes super-linear, the real fix is to
886                // reduce the workload (use --operations filter), not bump VUs.
887                // Cap the suggestion at 1000 and steer the user toward filtering.
888                const VU_RECOMMENDATION_CAP: u32 = 1000;
889                let recommendation = required_vus.max(self.vus + 1);
890                if recommendation > VU_RECOMMENDATION_CAP {
891                    TerminalReporter::print_warning(&format!(
892                        "Workload is very large: --rps {} × {} ops/iteration × {} \
893                         baseline ⇒ ~{} VUs needed end-to-end, far beyond what's \
894                         practical to drive. Two ways to fix:\n  1. Reduce \
895                         operations per iteration with `--operations 'pattern,…'` \
896                         (or `--exclude-operations`) to focus the bench on a \
897                         representative subset.\n  2. Drop `--rps` and use \
898                         `--vus {}` alone — closed-model load runs as fast as \
899                         the VU pool allows, bounded by latency, with no per-\
900                         iteration deadline. Expect 1-iteration coverage of ~{} \
901                         operations in {}s.",
902                        rps,
903                        num_ops,
904                        basis,
905                        recommendation,
906                        self.vus.max(5),
907                        num_ops,
908                        Self::parse_duration(&self.duration).unwrap_or(0),
909                    ));
910                } else {
911                    TerminalReporter::print_warning(&format!(
912                        "--vus {} may be insufficient for --rps {} × {} ops/iteration \
913                         (baseline latency {}). k6's constant-arrival-rate counts ITERATIONS \
914                         and each runs every operation in the spec — required ≈ rps × ops × \
915                         latency_secs VUs. Bump --vus to ~{} if you see \"Insufficient VUs\" \
916                         warnings.",
917                        self.vus, rps, num_ops, basis, recommendation,
918                    ));
919                }
920            } else if probe.is_some() {
921                TerminalReporter::print_progress(&format!(
922                    "Pre-flight probe: target latency {}, {} ops/iteration — --vus {} \
923                     is sufficient for --rps {}",
924                    basis, num_ops, self.vus, rps,
925                ));
926            }
927        }
928
929        let k6_config = K6Config {
930            target_url: self.target.clone(),
931            base_path,
932            scenario,
933            duration_secs: Self::parse_duration(&self.duration)?,
934            max_vus: self.vus,
935            threshold_percentile: self.threshold_percentile.clone(),
936            threshold_ms: self.threshold_ms,
937            max_error_rate: self.max_error_rate,
938            auth_header: self.auth.clone(),
939            custom_headers,
940            skip_tls_verify: self.skip_tls_verify,
941            security_testing_enabled,
942            chunked_request_bodies: self.chunked_request_bodies,
943            target_rps: self.target_rps,
944            no_keep_alive: self.no_keep_alive,
945            // Round 22.3 — wire `--geo-source-ip` / `--geo-source-header`
946            // through to the k6 generator so the rendered script
947            // rotates the forwarded-IP headers per iteration. Pre-fix
948            // these were Vec::new() and the script never set the
949            // headers in bench mode.
950            geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip")
951                .into_iter()
952                .map(|ip| ip.to_string())
953                .collect(),
954            geo_source_headers: if self.geo_source_headers.is_empty()
955                && !self.geo_source_ips.is_empty()
956            {
957                crate::conformance::self_test::default_geo_source_headers()
958            } else {
959                self.geo_source_headers.clone()
960            },
961        };
962
963        let generator = K6ScriptGenerator::new(k6_config, templates)
964            .with_abort_valve(self.abort_on_error, self.abort_on_error_rate);
965        let mut script = generator.generate()?;
966        TerminalReporter::print_success("k6 script generated");
967
968        // Check if any advanced features are enabled
969        let has_advanced_features = self.data_file.is_some()
970            || self.error_rate.is_some()
971            || self.security_test
972            || self.parallel_create.is_some()
973            || self.wafbench_dir.is_some();
974
975        // Enhance script with advanced features
976        if has_advanced_features {
977            script = self.generate_enhanced_script(&script)?;
978        }
979
980        // Add mock server integration code
981        if mock_config.is_mock_server {
982            let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
983            let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
984            let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
985
986            // Insert mock server code after imports
987            if let Some(import_end) = script.find("export const options") {
988                script.insert_str(
989                    import_end,
990                    &format!(
991                        "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
992                        helper_code, setup_code, teardown_code
993                    ),
994                );
995            }
996        }
997
998        // Validate the generated script
999        TerminalReporter::print_progress("Validating k6 script...");
1000        let validation_errors = K6ScriptGenerator::validate_script(&script);
1001        if !validation_errors.is_empty() {
1002            TerminalReporter::print_error("Script validation failed");
1003            for error in &validation_errors {
1004                eprintln!("  {}", error);
1005            }
1006            return Err(BenchError::Other(format!(
1007                "Generated k6 script has {} validation error(s). Please check the output above.",
1008                validation_errors.len()
1009            )));
1010        }
1011        TerminalReporter::print_success("Script validation passed");
1012
1013        // Write script to file
1014        let script_path = if let Some(output) = &self.script_output {
1015            output.clone()
1016        } else {
1017            self.output.join("k6-script.js")
1018        };
1019
1020        if let Some(parent) = script_path.parent() {
1021            std::fs::create_dir_all(parent)?;
1022        }
1023        std::fs::write(&script_path, &script)?;
1024        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1025
1026        // If generate-only mode, exit here
1027        if self.generate_only {
1028            println!("\nScript generated successfully. Run it with:");
1029            println!("  k6 run {}", script_path.display());
1030            return Ok(());
1031        }
1032
1033        // Execute k6
1034        TerminalReporter::print_progress("Executing load test...");
1035        // Round 57 (#79) — `--discard-response-bodies` opts a single-target
1036        // load run into K6_DISCARD_RESPONSE_BODIES. Safe here: this is the
1037        // plain load path (status/latency only), not conformance/extraction.
1038        let executor = K6Executor::new()?
1039            .with_local_ips(self.source_ips.join(","))
1040            .with_dns_policy(self.dns_policy.clone().unwrap_or_default())
1041            .with_discard_response_bodies(self.discard_response_bodies);
1042
1043        std::fs::create_dir_all(&self.output)?;
1044
1045        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1046
1047        // Print results
1048        let duration_secs = Self::parse_duration(&self.duration)?;
1049        TerminalReporter::print_summary_full(
1050            &results,
1051            duration_secs,
1052            self.no_keep_alive,
1053            Some(num_ops),
1054        );
1055
1056        println!("\nResults saved to: {}", self.output.display());
1057
1058        Ok(())
1059    }
1060
1061    /// Execute multi-target bench testing
1062    async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
1063        TerminalReporter::print_progress("Parsing targets file...");
1064        let targets = parse_targets_file(targets_file)?;
1065        let num_targets = targets.len();
1066        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
1067
1068        if targets.is_empty() {
1069            return Err(BenchError::Other("No targets found in file".to_string()));
1070        }
1071
1072        // Determine max concurrency
1073        let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
1074        let max_concurrency = max_concurrency.min(num_targets); // Don't exceed number of targets
1075
1076        // Print header for multi-target mode
1077        TerminalReporter::print_header(
1078            &self.get_spec_display_name(),
1079            &format!("{} targets", num_targets),
1080            0,
1081            &self.scenario,
1082            Self::parse_duration(&self.duration)?,
1083        );
1084
1085        // Create parallel executor
1086        let executor = ParallelExecutor::new(
1087            BenchCommand {
1088                // Clone all fields except targets_file (we don't need it in the executor)
1089                spec: self.spec.clone(),
1090                spec_dir: self.spec_dir.clone(),
1091                merge_conflicts: self.merge_conflicts.clone(),
1092                spec_mode: self.spec_mode.clone(),
1093                dependency_config: self.dependency_config.clone(),
1094                target: self.target.clone(), // Not used in multi-target mode, but kept for compatibility
1095                base_path: self.base_path.clone(),
1096                duration: self.duration.clone(),
1097                vus: self.vus,
1098                target_rps: self.target_rps,
1099                no_keep_alive: self.no_keep_alive,
1100                scenario: self.scenario.clone(),
1101                operations: self.operations.clone(),
1102                exclude_operations: self.exclude_operations.clone(),
1103                auth: self.auth.clone(),
1104                headers: self.headers.clone(),
1105                output: self.output.clone(),
1106                generate_only: self.generate_only,
1107                script_output: self.script_output.clone(),
1108                threshold_percentile: self.threshold_percentile.clone(),
1109                threshold_ms: self.threshold_ms,
1110                max_error_rate: self.max_error_rate,
1111                abort_on_error: self.abort_on_error,
1112                abort_on_error_rate: self.abort_on_error_rate,
1113                verbose: self.verbose,
1114                skip_tls_verify: self.skip_tls_verify,
1115                chunked_request_bodies: self.chunked_request_bodies,
1116                targets_file: None,
1117                max_concurrency: None,
1118                results_format: self.results_format.clone(),
1119                params_file: self.params_file.clone(),
1120                crud_flow: self.crud_flow,
1121                flow_config: self.flow_config.clone(),
1122                extract_fields: self.extract_fields.clone(),
1123                parallel_create: self.parallel_create,
1124                data_file: self.data_file.clone(),
1125                data_distribution: self.data_distribution.clone(),
1126                data_mappings: self.data_mappings.clone(),
1127                per_uri_control: self.per_uri_control,
1128                error_rate: self.error_rate,
1129                error_types: self.error_types.clone(),
1130                security_test: self.security_test,
1131                security_payloads: self.security_payloads.clone(),
1132                security_categories: self.security_categories.clone(),
1133                security_target_fields: self.security_target_fields.clone(),
1134                wafbench_dir: self.wafbench_dir.clone(),
1135                wafbench_cycle_all: self.wafbench_cycle_all,
1136                wafbench_verbatim: self.wafbench_verbatim,
1137                owasp_api_top10: self.owasp_api_top10,
1138                owasp_categories: self.owasp_categories.clone(),
1139                owasp_auth_header: self.owasp_auth_header.clone(),
1140                owasp_auth_token: self.owasp_auth_token.clone(),
1141                owasp_admin_paths: self.owasp_admin_paths.clone(),
1142                owasp_id_fields: self.owasp_id_fields.clone(),
1143                owasp_report: self.owasp_report.clone(),
1144                owasp_report_format: self.owasp_report_format.clone(),
1145                owasp_iterations: self.owasp_iterations,
1146                conformance: false,
1147                // Round 64 (#79) — Srikanth on 0.3.210 ran
1148                //   mockforge bench --use-k6 --targets-file vs_list3.json \
1149                //     --conformance-basic-auth user:pass ...
1150                // and the credentials never reached the wire (absent from both
1151                // his PCAP and his proxy). Round 47 taught `parse_headers()` to
1152                // fold these auth shortcuts into the shared header map so the
1153                // same flags work for plain bench, but THIS clone nulled them
1154                // out before `ParallelExecutor` ever called `parse_headers()`.
1155                // Single-target worked; multi-target silently sent nothing.
1156                //
1157                // These three must survive the clone or the fold has nothing to
1158                // fold. `conformance_api_key` is still conformance-only by
1159                // design (see parse_headers), but is preserved so multi-target
1160                // emits the same "only fires under --conformance" warning as
1161                // single-target rather than swallowing the flag.
1162                conformance_api_key: self.conformance_api_key.clone(),
1163                conformance_basic_auth: self.conformance_basic_auth.clone(),
1164                conformance_report: PathBuf::from("conformance-report.json"),
1165                conformance_categories: None,
1166                conformance_report_format: "json".to_string(),
1167                // Also carries round 46's `--auth-bearer`, which CLI dispatch
1168                // injects here as `Authorization: Bearer ...`. Dropping it had
1169                // the same effect as dropping --conformance-basic-auth.
1170                conformance_headers: self.conformance_headers.clone(),
1171                conformance_all_operations: false,
1172                conformance_custom: None,
1173                conformance_delay_ms: 0,
1174                use_k6: false,
1175                conformance_custom_filter: None,
1176                export_requests: false,
1177                validate_requests: false,
1178                conformance_self_test: false,
1179                conformance_self_test_capture: false,
1180                conformance_self_test_iterations: 1,
1181                conformance_self_test_duration: None,
1182                validate_response_schemas: false,
1183                // Issue #79 r54 (Srikanth on 0.3.200): these were hardcoded to
1184                // empty, so `--source-ip` / `--geo-source-ip` were silently
1185                // dropped in multi-target (`--targets-file`) mode. Carry them
1186                // through to the ParallelExecutor so k6 gets `--local-ips`.
1187                source_ips: self.source_ips.clone(),
1188                geo_source_ips: self.geo_source_ips.clone(),
1189                geo_source_headers: self.geo_source_headers.clone(),
1190                report_missed_cap: None,
1191                // Round 57 (#79) — carry the flag through; the multi-target
1192                // plain-load path already discards by default (r56), so this
1193                // keeps the per-target command faithful to the parent.
1194                discard_response_bodies: self.discard_response_bodies,
1195                // Round 61 (#79) — carry the DNS policy so per-target k6 runs
1196                // resolve hostnames with the same IPv6/IPv4 preference.
1197                dns_policy: self.dns_policy.clone(),
1198            },
1199            targets,
1200            max_concurrency,
1201        );
1202
1203        // Execute all targets
1204        let start_time = std::time::Instant::now();
1205        let aggregated_results = executor.execute_all().await?;
1206        let elapsed = start_time.elapsed();
1207
1208        // Organize and report results
1209        self.report_multi_target_results(&aggregated_results, elapsed)?;
1210
1211        Ok(())
1212    }
1213
1214    /// Report results for multi-target execution
1215    fn report_multi_target_results(
1216        &self,
1217        results: &AggregatedResults,
1218        elapsed: std::time::Duration,
1219    ) -> Result<()> {
1220        // Print summary
1221        TerminalReporter::print_multi_target_summary(results);
1222
1223        // Print elapsed time
1224        let total_secs = elapsed.as_secs();
1225        let hours = total_secs / 3600;
1226        let minutes = (total_secs % 3600) / 60;
1227        let seconds = total_secs % 60;
1228        if hours > 0 {
1229            println!("\n  Total Elapsed Time:   {}h {}m {}s", hours, minutes, seconds);
1230        } else if minutes > 0 {
1231            println!("\n  Total Elapsed Time:   {}m {}s", minutes, seconds);
1232        } else {
1233            println!("\n  Total Elapsed Time:   {}s", seconds);
1234        }
1235
1236        // Save aggregated summary if requested
1237        if self.results_format == "aggregated" || self.results_format == "both" {
1238            let summary_path = self.output.join("aggregated_summary.json");
1239            let summary_json = serde_json::json!({
1240                "total_elapsed_seconds": elapsed.as_secs(),
1241                "total_targets": results.total_targets,
1242                "successful_targets": results.successful_targets,
1243                "failed_targets": results.failed_targets,
1244                "aggregated_metrics": {
1245                    "total_requests": results.aggregated_metrics.total_requests,
1246                    "total_failed_requests": results.aggregated_metrics.total_failed_requests,
1247                    "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
1248                    "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
1249                    "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
1250                    "error_rate": results.aggregated_metrics.error_rate,
1251                    "total_rps": results.aggregated_metrics.total_rps,
1252                    "avg_rps": results.aggregated_metrics.avg_rps,
1253                    "total_vus_max": results.aggregated_metrics.total_vus_max,
1254                },
1255                "target_results": results.target_results.iter().map(|r| {
1256                    serde_json::json!({
1257                        "target_url": r.target_url,
1258                        "target_index": r.target_index,
1259                        "success": r.success,
1260                        "error": r.error,
1261                        "total_requests": r.results.total_requests,
1262                        "failed_requests": r.results.failed_requests,
1263                        "avg_duration_ms": r.results.avg_duration_ms,
1264                        "min_duration_ms": r.results.min_duration_ms,
1265                        "med_duration_ms": r.results.med_duration_ms,
1266                        "p90_duration_ms": r.results.p90_duration_ms,
1267                        "p95_duration_ms": r.results.p95_duration_ms,
1268                        "p99_duration_ms": r.results.p99_duration_ms,
1269                        "max_duration_ms": r.results.max_duration_ms,
1270                        "rps": r.results.rps,
1271                        "vus_max": r.results.vus_max,
1272                        "output_dir": r.output_dir.to_string_lossy(),
1273                    })
1274                }).collect::<Vec<_>>(),
1275            });
1276
1277            std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
1278            TerminalReporter::print_success(&format!(
1279                "Aggregated summary saved to: {}",
1280                summary_path.display()
1281            ));
1282        }
1283
1284        // Write CSV with all per-target results for easy parsing
1285        let csv_path = self.output.join("all_targets.csv");
1286        let mut csv = String::from(
1287            "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
1288        );
1289        for r in &results.target_results {
1290            csv.push_str(&format!(
1291                "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
1292                r.target_url,
1293                r.success,
1294                r.results.total_requests,
1295                r.results.failed_requests,
1296                r.results.rps,
1297                r.results.vus_max,
1298                r.results.min_duration_ms,
1299                r.results.avg_duration_ms,
1300                r.results.med_duration_ms,
1301                r.results.p90_duration_ms,
1302                r.results.p95_duration_ms,
1303                r.results.p99_duration_ms,
1304                r.results.max_duration_ms,
1305                r.error.as_deref().unwrap_or(""),
1306            ));
1307        }
1308        let _ = std::fs::write(&csv_path, &csv);
1309
1310        println!("\nResults saved to: {}", self.output.display());
1311        println!("  - Per-target results: {}", self.output.join("target_*").display());
1312        println!("  - All targets CSV:    {}", csv_path.display());
1313        if self.results_format == "aggregated" || self.results_format == "both" {
1314            println!(
1315                "  - Aggregated summary: {}",
1316                self.output.join("aggregated_summary.json").display()
1317            );
1318        }
1319
1320        Ok(())
1321    }
1322
1323    /// Parse duration string (e.g., "30s", "5m", "1h") to seconds
1324    pub fn parse_duration(duration: &str) -> Result<u64> {
1325        let duration = duration.trim();
1326
1327        if let Some(secs) = duration.strip_suffix('s') {
1328            secs.parse::<u64>()
1329                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1330        } else if let Some(mins) = duration.strip_suffix('m') {
1331            mins.parse::<u64>()
1332                .map(|m| m * 60)
1333                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1334        } else if let Some(hours) = duration.strip_suffix('h') {
1335            hours
1336                .parse::<u64>()
1337                .map(|h| h * 3600)
1338                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1339        } else {
1340            // Try parsing as seconds without suffix
1341            duration
1342                .parse::<u64>()
1343                .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
1344        }
1345    }
1346
1347    /// Load traffic cases from `--wafbench-dir` and turn them into templates
1348    /// that are sent exactly as written (#994).
1349    ///
1350    /// Reuses the same loader as the payload path, so every input form keeps
1351    /// working: a single file, a directory (recursive), or a glob. Only the
1352    /// interpretation changes.
1353    fn load_verbatim_templates(&self) -> Result<Vec<crate::request_gen::RequestTemplate>> {
1354        let Some(pattern) = self.wafbench_dir.as_ref() else {
1355            return Err(BenchError::Other(
1356                "--wafbench-verbatim requires --wafbench-dir pointing at your traffic file(s)"
1357                    .to_string(),
1358            ));
1359        };
1360
1361        let mut loader = WafBenchLoader::new();
1362        loader.load_from_pattern(pattern)?;
1363
1364        Ok(crate::wafbench::traffic_cases_to_templates(loader.test_cases()))
1365    }
1366
1367    /// Parse headers from the repeated `--headers "Key:Value"` flags.
1368    pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
1369        let mut headers = parse_header_string(&self.headers)?;
1370
1371        // Round 47 (#79) — Srikanth on 0.3.191 tried
1372        // `mockforge bench --conformance-basic-auth user:pass --spec ...`
1373        // (no `--conformance` flag) and the auth never landed because
1374        // the bench path only reads `self.headers`. Fold the
1375        // conformance auth shortcuts (basic, api-key, the round-46
1376        // --auth-bearer collected in `conformance_headers`) into the
1377        // shared header map so the SAME auth flags work whether the
1378        // run is plain bench, conformance, or self-test. De-dup
1379        // case-insensitively on header name; explicit
1380        // `--header "Authorization: ..."` keeps priority.
1381        let already_has = |hs: &HashMap<String, String>, name: &str| -> bool {
1382            hs.keys().any(|k| k.eq_ignore_ascii_case(name))
1383        };
1384
1385        if !already_has(&headers, "Authorization") {
1386            if let Some(b) = self.conformance_basic_auth.as_ref().filter(|s| !s.is_empty()) {
1387                use base64::Engine as _;
1388                let encoded = base64::engine::general_purpose::STANDARD.encode(b.as_bytes());
1389                headers.insert("Authorization".to_string(), format!("Basic {}", encoded));
1390            }
1391        }
1392
1393        // `--conformance-headers "Name: value"` was already conformance-
1394        // only; reuse the same flag for bench. Round 46's --auth-bearer
1395        // injects `Authorization: Bearer ...` into this list at CLI
1396        // dispatch time, so this single pass also picks up the bearer
1397        // token for plain bench.
1398        for line in &self.conformance_headers {
1399            let Some((name, value)) = line.split_once(':') else {
1400                continue;
1401            };
1402            let name = name.trim();
1403            let value = value.trim();
1404            if name.is_empty() || already_has(&headers, name) {
1405                continue;
1406            }
1407            headers.insert(name.to_string(), value.to_string());
1408        }
1409
1410        // `--conformance-api-key` is conformance-test-specific (probe
1411        // pattern over multiple header names). Forwarding it to plain
1412        // bench as a simple header would mislead a user expecting the
1413        // probe behaviour, so we leave that path conformance-only and
1414        // surface a one-line note when it's set without `--conformance`.
1415        if !self.conformance && self.conformance_api_key.is_some() {
1416            TerminalReporter::print_warning(
1417                "--conformance-api-key only fires under --conformance. For plain bench use --header 'X-API-Key: ...'.",
1418            );
1419        }
1420
1421        Ok(headers)
1422    }
1423
1424    fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
1425        let extracted_path = output_dir.join("extracted_values.json");
1426        if !extracted_path.exists() {
1427            return Ok(ExtractedValues::new());
1428        }
1429
1430        let content = std::fs::read_to_string(&extracted_path)
1431            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
1432        let parsed: serde_json::Value = serde_json::from_str(&content)
1433            .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
1434
1435        let mut extracted = ExtractedValues::new();
1436        if let Some(values) = parsed.as_object() {
1437            for (key, value) in values {
1438                extracted.set(key.clone(), value.clone());
1439            }
1440        }
1441
1442        Ok(extracted)
1443    }
1444
1445    /// Resolve the effective base path for API endpoints
1446    ///
1447    /// Priority:
1448    /// 1. CLI --base-path option (if provided, even if empty string)
1449    /// 2. Base path extracted from OpenAPI spec's servers URL
1450    /// 3. None (no base path)
1451    ///
1452    /// An empty string from CLI explicitly disables base path.
1453    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
1454        // CLI option takes priority (including empty string to disable)
1455        if let Some(cli_base_path) = &self.base_path {
1456            if cli_base_path.is_empty() {
1457                // Empty string explicitly means "no base path"
1458                return None;
1459            }
1460            return Some(cli_base_path.clone());
1461        }
1462
1463        // Fall back to spec's base path
1464        parser.get_base_path()
1465    }
1466
1467    /// Build mock server integration configuration
1468    async fn build_mock_config(&self) -> MockIntegrationConfig {
1469        // Check if target looks like a mock server
1470        if MockServerDetector::looks_like_mock_server(&self.target) {
1471            // Try to detect if it's actually a MockForge server
1472            if let Ok(info) = MockServerDetector::detect(&self.target).await {
1473                if info.is_mockforge {
1474                    TerminalReporter::print_success(&format!(
1475                        "Detected MockForge server (version: {})",
1476                        info.version.as_deref().unwrap_or("unknown")
1477                    ));
1478                    return MockIntegrationConfig::mock_server();
1479                }
1480            }
1481        }
1482        MockIntegrationConfig::real_api()
1483    }
1484
1485    /// Build CRUD flow configuration
1486    fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
1487        if !self.crud_flow {
1488            return None;
1489        }
1490
1491        // If flow_config file is provided, load it
1492        if let Some(config_path) = &self.flow_config {
1493            match CrudFlowConfig::from_file(config_path) {
1494                Ok(config) => return Some(config),
1495                Err(e) => {
1496                    TerminalReporter::print_warning(&format!(
1497                        "Failed to load flow config: {}. Using auto-detection.",
1498                        e
1499                    ));
1500                }
1501            }
1502        }
1503
1504        // Parse extract fields
1505        let extract_fields = self
1506            .extract_fields
1507            .as_ref()
1508            .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
1509            .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
1510
1511        Some(CrudFlowConfig {
1512            flows: Vec::new(), // Will be auto-detected
1513            default_extract_fields: extract_fields,
1514        })
1515    }
1516
1517    /// Build data-driven testing configuration
1518    fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
1519        let data_file = self.data_file.as_ref()?;
1520
1521        let distribution = DataDistribution::from_str(&self.data_distribution)
1522            .unwrap_or(DataDistribution::UniquePerVu);
1523
1524        let mappings = self
1525            .data_mappings
1526            .as_ref()
1527            .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
1528            .unwrap_or_default();
1529
1530        Some(DataDrivenConfig {
1531            file_path: data_file.to_string_lossy().to_string(),
1532            distribution,
1533            mappings,
1534            csv_has_header: true,
1535            per_uri_control: self.per_uri_control,
1536            per_uri_columns: crate::data_driven::PerUriColumns::default(),
1537        })
1538    }
1539
1540    /// Build invalid data testing configuration
1541    fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
1542        let error_rate = self.error_rate?;
1543
1544        let error_types = self
1545            .error_types
1546            .as_ref()
1547            .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
1548            .unwrap_or_default();
1549
1550        Some(InvalidDataConfig {
1551            error_rate,
1552            error_types,
1553            target_fields: Vec::new(),
1554        })
1555    }
1556
1557    /// Build security testing configuration
1558    fn build_security_config(&self) -> Option<SecurityTestConfig> {
1559        if !self.security_test {
1560            return None;
1561        }
1562
1563        let categories = self
1564            .security_categories
1565            .as_ref()
1566            .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
1567            .unwrap_or_else(|| {
1568                let mut default = HashSet::new();
1569                default.insert(SecurityCategory::SqlInjection);
1570                default.insert(SecurityCategory::Xss);
1571                default
1572            });
1573
1574        let target_fields = self
1575            .security_target_fields
1576            .as_ref()
1577            .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
1578            .unwrap_or_default();
1579
1580        let custom_payloads_file =
1581            self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
1582
1583        Some(SecurityTestConfig {
1584            enabled: true,
1585            categories,
1586            target_fields,
1587            custom_payloads_file,
1588            include_high_risk: false,
1589        })
1590    }
1591
1592    /// Build parallel execution configuration
1593    fn build_parallel_config(&self) -> Option<ParallelConfig> {
1594        let count = self.parallel_create?;
1595
1596        Some(ParallelConfig::new(count))
1597    }
1598
1599    /// Load WAFBench payloads from the specified directory or pattern
1600    fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
1601        let Some(ref wafbench_dir) = self.wafbench_dir else {
1602            return Vec::new();
1603        };
1604
1605        let mut loader = WafBenchLoader::new();
1606
1607        if let Err(e) = loader.load_from_pattern(wafbench_dir) {
1608            TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
1609            return Vec::new();
1610        }
1611
1612        let stats = loader.stats();
1613
1614        if stats.files_processed == 0 {
1615            TerminalReporter::print_warning(&format!(
1616                "No WAFBench YAML files found matching '{}'",
1617                wafbench_dir
1618            ));
1619            // Also report any parse errors that may explain why no files were processed
1620            if !stats.parse_errors.is_empty() {
1621                TerminalReporter::print_warning("Some files were found but failed to parse:");
1622                for error in &stats.parse_errors {
1623                    TerminalReporter::print_warning(&format!("  - {}", error));
1624                }
1625            }
1626            return Vec::new();
1627        }
1628
1629        TerminalReporter::print_progress(&format!(
1630            "Loaded {} WAFBench files, {} test cases, {} payloads",
1631            stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
1632        ));
1633
1634        // Print category breakdown
1635        for (category, count) in &stats.by_category {
1636            TerminalReporter::print_progress(&format!("  - {}: {} tests", category, count));
1637        }
1638
1639        // Report any parse errors
1640        for error in &stats.parse_errors {
1641            TerminalReporter::print_warning(&format!("  Parse error: {}", error));
1642        }
1643
1644        loader.to_security_payloads()
1645    }
1646
1647    /// Generate enhanced k6 script with advanced features
1648    pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
1649        let mut enhanced_script = base_script.to_string();
1650        let mut additional_code = String::new();
1651
1652        // Add data-driven testing code
1653        if let Some(config) = self.build_data_driven_config() {
1654            TerminalReporter::print_progress("Adding data-driven testing support...");
1655            additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
1656            additional_code.push('\n');
1657            TerminalReporter::print_success("Data-driven testing enabled");
1658        }
1659
1660        // Add invalid data generation code
1661        if let Some(config) = self.build_invalid_data_config() {
1662            TerminalReporter::print_progress("Adding invalid data testing support...");
1663            additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
1664            additional_code.push('\n');
1665            additional_code
1666                .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
1667            additional_code.push('\n');
1668            additional_code
1669                .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
1670            additional_code.push('\n');
1671            TerminalReporter::print_success(&format!(
1672                "Invalid data testing enabled ({}% error rate)",
1673                (self.error_rate.unwrap_or(0.0) * 100.0) as u32
1674            ));
1675        }
1676
1677        // Add security testing code.
1678        //
1679        // #997: in verbatim mode nothing may be injected — see
1680        // `security_testing_enabled`. Both the payload pool and the
1681        // "requested" flag must go quiet together, otherwise the else-branch
1682        // below emits a warning about payloads that were never wanted.
1683        let verbatim = self.wafbench_verbatim;
1684        if verbatim && self.security_test {
1685            TerminalReporter::print_warning(
1686                "--security-test is ignored under --wafbench-verbatim: verbatim mode sends your \
1687                 traffic cases exactly as written and will not append attack payloads to them. \
1688                 Drop --wafbench-verbatim if you want payload injection.",
1689            );
1690        }
1691        let security_config = if verbatim {
1692            None
1693        } else {
1694            self.build_security_config()
1695        };
1696        let wafbench_payloads = if verbatim {
1697            Vec::new()
1698        } else {
1699            self.load_wafbench_payloads()
1700        };
1701        let security_requested =
1702            !verbatim && (security_config.is_some() || self.wafbench_dir.is_some());
1703
1704        if security_config.is_some() || !wafbench_payloads.is_empty() {
1705            TerminalReporter::print_progress("Adding security testing support...");
1706
1707            // Combine built-in payloads with WAFBench payloads
1708            let mut payload_list: Vec<SecurityPayload> = Vec::new();
1709
1710            if let Some(ref config) = security_config {
1711                payload_list.extend(SecurityPayloads::get_payloads(config));
1712            }
1713
1714            // Add WAFBench payloads
1715            if !wafbench_payloads.is_empty() {
1716                TerminalReporter::print_progress(&format!(
1717                    "Loading {} WAFBench attack patterns...",
1718                    wafbench_payloads.len()
1719                ));
1720                payload_list.extend(wafbench_payloads);
1721            }
1722
1723            let target_fields =
1724                security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
1725
1726            additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
1727                &payload_list,
1728                self.wafbench_cycle_all,
1729            ));
1730            additional_code.push('\n');
1731            additional_code
1732                .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
1733            additional_code.push('\n');
1734            additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
1735            additional_code.push('\n');
1736
1737            let mode = if self.wafbench_cycle_all {
1738                "cycle-all"
1739            } else {
1740                "random"
1741            };
1742            TerminalReporter::print_success(&format!(
1743                "Security testing enabled ({} payloads, {} mode)",
1744                payload_list.len(),
1745                mode
1746            ));
1747        } else if security_requested {
1748            // User requested security testing (e.g., --wafbench-dir) but no payloads were loaded.
1749            // The template has security_testing_enabled=true so it renders calling code.
1750            // We must inject stub definitions to avoid undefined function references.
1751            TerminalReporter::print_warning(
1752                "Security testing was requested but no payloads were loaded. \
1753                 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
1754            );
1755            additional_code
1756                .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
1757            additional_code.push('\n');
1758            additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1759            additional_code.push('\n');
1760        }
1761
1762        // Add parallel execution code
1763        if let Some(config) = self.build_parallel_config() {
1764            TerminalReporter::print_progress("Adding parallel execution support...");
1765            additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1766            additional_code.push('\n');
1767            TerminalReporter::print_success(&format!(
1768                "Parallel execution enabled (count: {})",
1769                config.count
1770            ));
1771        }
1772
1773        // Insert additional code after the imports section
1774        if !additional_code.is_empty() {
1775            // Find the end of the import section
1776            if let Some(import_end) = enhanced_script.find("export const options") {
1777                enhanced_script.insert_str(
1778                    import_end,
1779                    &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1780                );
1781            }
1782        }
1783
1784        Ok(enhanced_script)
1785    }
1786
1787    /// Execute specs sequentially with dependency ordering and value passing
1788    async fn execute_sequential_specs(&self) -> Result<()> {
1789        TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1790
1791        // Load all specs (without merging)
1792        let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1793
1794        if !self.spec.is_empty() {
1795            let specs = load_specs_from_files(self.spec.clone())
1796                .await
1797                .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1798            all_specs.extend(specs);
1799        }
1800
1801        if let Some(spec_dir) = &self.spec_dir {
1802            let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1803                BenchError::Other(format!("Failed to load specs from directory: {}", e))
1804            })?;
1805            all_specs.extend(dir_specs);
1806        }
1807
1808        if all_specs.is_empty() {
1809            return Err(BenchError::Other(
1810                "No spec files found for sequential execution".to_string(),
1811            ));
1812        }
1813
1814        TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1815
1816        // Load dependency config or auto-detect
1817        let execution_order = if let Some(config_path) = &self.dependency_config {
1818            TerminalReporter::print_progress("Loading dependency configuration...");
1819            let config = SpecDependencyConfig::from_file(config_path)?;
1820
1821            if !config.disable_auto_detect && config.execution_order.is_empty() {
1822                // Auto-detect if config doesn't specify order
1823                self.detect_and_sort_specs(&all_specs)?
1824            } else {
1825                // Use configured order
1826                config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1827            }
1828        } else {
1829            // Auto-detect dependencies
1830            self.detect_and_sort_specs(&all_specs)?
1831        };
1832
1833        TerminalReporter::print_success(&format!(
1834            "Execution order: {}",
1835            execution_order
1836                .iter()
1837                .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1838                .collect::<Vec<_>>()
1839                .join(" → ")
1840        ));
1841
1842        // Execute each spec in order
1843        let mut extracted_values = ExtractedValues::new();
1844        let total_specs = execution_order.len();
1845
1846        for (index, spec_path) in execution_order.iter().enumerate() {
1847            let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1848
1849            TerminalReporter::print_progress(&format!(
1850                "[{}/{}] Executing spec: {}",
1851                index + 1,
1852                total_specs,
1853                spec_name
1854            ));
1855
1856            // Find the spec in our loaded specs (match by full path or filename)
1857            let spec = all_specs
1858                .iter()
1859                .find(|(p, _)| {
1860                    p == spec_path
1861                        || p.file_name() == spec_path.file_name()
1862                        || p.file_name() == Some(spec_path.as_os_str())
1863                })
1864                .map(|(_, s)| s.clone())
1865                .ok_or_else(|| {
1866                    BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1867                })?;
1868
1869            // Execute this spec with any extracted values from previous specs
1870            let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1871
1872            // Merge extracted values for the next spec
1873            extracted_values.merge(&new_values);
1874
1875            TerminalReporter::print_success(&format!(
1876                "[{}/{}] Completed: {} (extracted {} values)",
1877                index + 1,
1878                total_specs,
1879                spec_name,
1880                new_values.values.len()
1881            ));
1882        }
1883
1884        TerminalReporter::print_success(&format!(
1885            "Sequential execution complete: {} specs executed",
1886            total_specs
1887        ));
1888
1889        Ok(())
1890    }
1891
1892    /// Detect dependencies and return topologically sorted spec paths
1893    fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1894        TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1895
1896        let mut detector = DependencyDetector::new();
1897        let dependencies = detector.detect_dependencies(specs);
1898
1899        if dependencies.is_empty() {
1900            TerminalReporter::print_progress("No dependencies detected, using file order");
1901            return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1902        }
1903
1904        TerminalReporter::print_progress(&format!(
1905            "Detected {} cross-spec dependencies",
1906            dependencies.len()
1907        ));
1908
1909        for dep in &dependencies {
1910            TerminalReporter::print_progress(&format!(
1911                "  {} → {} (via field '{}')",
1912                dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1913                dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1914                dep.field_name
1915            ));
1916        }
1917
1918        topological_sort(specs, &dependencies)
1919    }
1920
1921    /// Execute a single spec and extract values for dependent specs
1922    async fn execute_single_spec(
1923        &self,
1924        spec: &OpenApiSpec,
1925        spec_name: &str,
1926        _external_values: &ExtractedValues,
1927    ) -> Result<ExtractedValues> {
1928        let parser = SpecParser::from_spec(spec.clone());
1929
1930        // For now, we execute in CRUD flow mode if enabled, otherwise standard mode
1931        if self.crud_flow {
1932            // Execute CRUD flow and extract values
1933            self.execute_crud_flow_with_extraction(&parser, spec_name).await
1934        } else {
1935            // Execute standard benchmark (no value extraction in non-CRUD mode)
1936            self.execute_standard_spec(&parser, spec_name).await?;
1937            Ok(ExtractedValues::new())
1938        }
1939    }
1940
1941    /// Execute CRUD flow with value extraction for sequential mode
1942    async fn execute_crud_flow_with_extraction(
1943        &self,
1944        parser: &SpecParser,
1945        spec_name: &str,
1946    ) -> Result<ExtractedValues> {
1947        let operations = parser.get_operations();
1948        let flows = CrudFlowDetector::detect_flows(&operations);
1949
1950        if flows.is_empty() {
1951            TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1952            return Ok(ExtractedValues::new());
1953        }
1954
1955        TerminalReporter::print_progress(&format!(
1956            "  {} CRUD flow(s) in {}",
1957            flows.len(),
1958            spec_name
1959        ));
1960
1961        // Generate and execute the CRUD flow script
1962        let mut handlebars = handlebars::Handlebars::new();
1963        // Register json helper for serializing arrays/objects in templates
1964        handlebars.register_helper(
1965            "json",
1966            Box::new(
1967                |h: &handlebars::Helper,
1968                 _: &handlebars::Handlebars,
1969                 _: &handlebars::Context,
1970                 _: &mut handlebars::RenderContext,
1971                 out: &mut dyn handlebars::Output|
1972                 -> handlebars::HelperResult {
1973                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1974                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1975                    Ok(())
1976                },
1977            ),
1978        );
1979        let template = include_str!("templates/k6_crud_flow.hbs");
1980        let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1981
1982        let custom_headers = self.parse_headers()?;
1983        let config = self.build_crud_flow_config().unwrap_or_default();
1984
1985        // Load parameter overrides if provided (for body configurations)
1986        let param_overrides = if let Some(params_file) = &self.params_file {
1987            let overrides = ParameterOverrides::from_file(params_file)?;
1988            Some(overrides)
1989        } else {
1990            None
1991        };
1992
1993        // Generate stages from scenario
1994        let duration_secs = Self::parse_duration(&self.duration)?;
1995        let scenario =
1996            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1997        let stages = scenario.generate_stages(duration_secs, self.vus);
1998
1999        // Resolve base path (CLI option takes priority over spec's servers URL)
2000        let api_base_path = self.resolve_base_path(parser);
2001
2002        // Build headers JSON string for the template
2003        let mut all_headers = custom_headers.clone();
2004        if let Some(auth) = &self.auth {
2005            all_headers.insert("Authorization".to_string(), auth.clone());
2006        }
2007        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
2008
2009        // Track all dynamic placeholders across all operations
2010        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
2011
2012        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
2013            // Use the metric-name sanitizer (caps at 112 chars + hash suffix)
2014            // so deeply nested flow names don't blow past k6's 128-char limit
2015            // when concatenated with `_step{i}_latency`. See issue #79.
2016            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
2017            serde_json::json!({
2018                "name": sanitized_name.clone(),
2019                "display_name": f.name,
2020                "base_path": f.base_path,
2021                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
2022                    // Parse operation to get method and path
2023                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
2024                    let method_raw = if !parts.is_empty() {
2025                        parts[0].to_uppercase()
2026                    } else {
2027                        "GET".to_string()
2028                    };
2029                    let method = if !parts.is_empty() {
2030                        let m = parts[0].to_lowercase();
2031                        // k6 uses 'del' for DELETE
2032                        if m == "delete" { "del".to_string() } else { m }
2033                    } else {
2034                        "get".to_string()
2035                    };
2036                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
2037                    // Prepend API base path if configured
2038                    let path = if let Some(ref bp) = api_base_path {
2039                        format!("{}{}", bp, raw_path)
2040                    } else {
2041                        raw_path.to_string()
2042                    };
2043                    let is_get_or_head = method == "get" || method == "head";
2044                    // POST, PUT, PATCH typically have bodies
2045                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
2046
2047                    // Look up body from params file if available
2048                    let body_value = if has_body {
2049                        param_overrides.as_ref()
2050                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
2051                            .and_then(|oo| oo.body)
2052                            .unwrap_or_else(|| serde_json::json!({}))
2053                    } else {
2054                        serde_json::json!({})
2055                    };
2056
2057                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
2058                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
2059
2060                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
2061                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
2062                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
2063
2064                    serde_json::json!({
2065                        "operation": s.operation,
2066                        "method": method,
2067                        "path": path,
2068                        "extract": s.extract,
2069                        "use_values": s.use_values,
2070                        "use_body": s.use_body,
2071                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
2072                        "inject_attacks": s.inject_attacks,
2073                        "attack_types": s.attack_types,
2074                        "description": s.description,
2075                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
2076                        "is_get_or_head": is_get_or_head,
2077                        "has_body": has_body,
2078                        "body": processed_body.value,
2079                        "body_is_dynamic": body_is_dynamic,
2080                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
2081                    })
2082                }).collect::<Vec<_>>(),
2083            })
2084        }).collect();
2085
2086        // Collect all placeholders from all steps
2087        for flow_data in &flows_data {
2088            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
2089                for step in steps {
2090                    if let Some(placeholders_arr) =
2091                        step.get("_placeholders").and_then(|p| p.as_array())
2092                    {
2093                        for p_str in placeholders_arr {
2094                            if let Some(p_name) = p_str.as_str() {
2095                                match p_name {
2096                                    "VU" => {
2097                                        all_placeholders.insert(DynamicPlaceholder::VU);
2098                                    }
2099                                    "Iteration" => {
2100                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
2101                                    }
2102                                    "Timestamp" => {
2103                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
2104                                    }
2105                                    "UUID" => {
2106                                        all_placeholders.insert(DynamicPlaceholder::UUID);
2107                                    }
2108                                    "Random" => {
2109                                        all_placeholders.insert(DynamicPlaceholder::Random);
2110                                    }
2111                                    "Counter" => {
2112                                        all_placeholders.insert(DynamicPlaceholder::Counter);
2113                                    }
2114                                    "Date" => {
2115                                        all_placeholders.insert(DynamicPlaceholder::Date);
2116                                    }
2117                                    "VuIter" => {
2118                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
2119                                    }
2120                                    _ => {}
2121                                }
2122                            }
2123                        }
2124                    }
2125                }
2126            }
2127        }
2128
2129        // Get required imports and globals based on placeholders used
2130        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
2131        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
2132
2133        // Check if security testing is enabled
2134        let security_testing_enabled = self.security_testing_enabled();
2135
2136        let data = serde_json::json!({
2137            "base_url": self.target,
2138            "flows": flows_data,
2139            "extract_fields": config.default_extract_fields,
2140            "duration_secs": duration_secs,
2141            "max_vus": self.vus,
2142            "auth_header": self.auth,
2143            "custom_headers": custom_headers,
2144            "skip_tls_verify": self.skip_tls_verify,
2145            // Add missing template fields
2146            "stages": stages.iter().map(|s| serde_json::json!({
2147                "duration": s.duration,
2148                "target": s.target,
2149            })).collect::<Vec<_>>(),
2150            "threshold_percentile": self.threshold_percentile,
2151            "threshold_ms": self.threshold_ms,
2152            "max_error_rate": self.max_error_rate,
2153            "abort_on_error": self.abort_on_error,
2154            "abort_on_error_rate": self.abort_on_error_rate,
2155            "headers": headers_json,
2156            "dynamic_imports": required_imports,
2157            "dynamic_globals": required_globals,
2158            "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
2159            // Security testing settings
2160            "security_testing_enabled": security_testing_enabled,
2161            "has_custom_headers": !custom_headers.is_empty(),
2162        });
2163
2164        let mut script = handlebars
2165            .render_template(template, &data)
2166            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
2167
2168        // Enhance script with security testing support if enabled
2169        if security_testing_enabled {
2170            script = self.generate_enhanced_script(&script)?;
2171        }
2172
2173        // Write and execute script
2174        let script_path =
2175            self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
2176
2177        std::fs::create_dir_all(self.output.clone())?;
2178        std::fs::write(&script_path, &script)?;
2179
2180        if !self.generate_only {
2181            let executor = K6Executor::new()?
2182                .with_local_ips(self.source_ips.join(","))
2183                .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
2184            std::fs::create_dir_all(&output_dir)?;
2185
2186            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
2187
2188            let extracted = Self::parse_extracted_values(&output_dir)?;
2189            TerminalReporter::print_progress(&format!(
2190                "  Extracted {} value(s) from {}",
2191                extracted.values.len(),
2192                spec_name
2193            ));
2194            return Ok(extracted);
2195        }
2196
2197        Ok(ExtractedValues::new())
2198    }
2199
2200    /// Execute standard (non-CRUD) spec benchmark
2201    async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
2202        let mut operations = if let Some(filter) = &self.operations {
2203            parser.filter_operations(filter)?
2204        } else {
2205            parser.get_operations()
2206        };
2207
2208        if let Some(exclude) = &self.exclude_operations {
2209            operations = parser.exclude_operations(operations, exclude)?;
2210        }
2211
2212        if operations.is_empty() {
2213            TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
2214            return Ok(());
2215        }
2216
2217        TerminalReporter::print_progress(&format!(
2218            "  {} operations in {}",
2219            operations.len(),
2220            spec_name
2221        ));
2222
2223        // Generate request templates
2224        let templates: Vec<_> = operations
2225            .iter()
2226            .map(RequestGenerator::generate_template)
2227            .collect::<Result<Vec<_>>>()?;
2228
2229        // Parse headers
2230        let custom_headers = self.parse_headers()?;
2231
2232        // Resolve base path
2233        let base_path = self.resolve_base_path(parser);
2234
2235        // Generate k6 script
2236        let scenario =
2237            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
2238
2239        let security_testing_enabled = self.security_testing_enabled();
2240
2241        let k6_config = K6Config {
2242            target_url: self.target.clone(),
2243            base_path,
2244            scenario,
2245            duration_secs: Self::parse_duration(&self.duration)?,
2246            max_vus: self.vus,
2247            threshold_percentile: self.threshold_percentile.clone(),
2248            threshold_ms: self.threshold_ms,
2249            max_error_rate: self.max_error_rate,
2250            auth_header: self.auth.clone(),
2251            custom_headers,
2252            skip_tls_verify: self.skip_tls_verify,
2253            security_testing_enabled,
2254            chunked_request_bodies: self.chunked_request_bodies,
2255            target_rps: self.target_rps,
2256            no_keep_alive: self.no_keep_alive,
2257            // Round 22.3 — see other K6Config site above.
2258            geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip")
2259                .into_iter()
2260                .map(|ip| ip.to_string())
2261                .collect(),
2262            geo_source_headers: if self.geo_source_headers.is_empty()
2263                && !self.geo_source_ips.is_empty()
2264            {
2265                crate::conformance::self_test::default_geo_source_headers()
2266            } else {
2267                self.geo_source_headers.clone()
2268            },
2269        };
2270
2271        let generator = K6ScriptGenerator::new(k6_config, templates)
2272            .with_abort_valve(self.abort_on_error, self.abort_on_error_rate);
2273        let mut script = generator.generate()?;
2274
2275        // Enhance script with advanced features (security testing, etc.)
2276        let has_advanced_features = self.data_file.is_some()
2277            || self.error_rate.is_some()
2278            || self.security_test
2279            || self.parallel_create.is_some()
2280            || self.wafbench_dir.is_some();
2281
2282        if has_advanced_features {
2283            script = self.generate_enhanced_script(&script)?;
2284        }
2285
2286        // Write and execute script
2287        let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
2288
2289        std::fs::create_dir_all(self.output.clone())?;
2290        std::fs::write(&script_path, &script)?;
2291
2292        if !self.generate_only {
2293            // Round 57 (#79) — honour `--discard-response-bodies` on the
2294            // standard (status-only) load path too.
2295            let executor = K6Executor::new()?
2296                .with_local_ips(self.source_ips.join(","))
2297                .with_dns_policy(self.dns_policy.clone().unwrap_or_default())
2298                .with_discard_response_bodies(self.discard_response_bodies);
2299            let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
2300            std::fs::create_dir_all(&output_dir)?;
2301
2302            executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
2303        }
2304
2305        Ok(())
2306    }
2307
2308    /// Execute CRUD flow testing mode
2309    async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
2310        // Check if a custom flow config is provided
2311        let config = self.build_crud_flow_config().unwrap_or_default();
2312
2313        // Use flows from config if provided, otherwise auto-detect
2314        let flows = if !config.flows.is_empty() {
2315            TerminalReporter::print_progress("Using custom flow configuration...");
2316            config.flows.clone()
2317        } else {
2318            TerminalReporter::print_progress("Detecting CRUD operations...");
2319            let operations = parser.get_operations();
2320            CrudFlowDetector::detect_flows(&operations)
2321        };
2322
2323        if flows.is_empty() {
2324            return Err(BenchError::Other(
2325                "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
2326            ));
2327        }
2328
2329        if config.flows.is_empty() {
2330            TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
2331        } else {
2332            TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
2333        }
2334
2335        for flow in &flows {
2336            TerminalReporter::print_progress(&format!(
2337                "  - {}: {} steps",
2338                flow.name,
2339                flow.steps.len()
2340            ));
2341        }
2342
2343        // Generate CRUD flow script
2344        let mut handlebars = handlebars::Handlebars::new();
2345        // Register json helper for serializing arrays/objects in templates
2346        handlebars.register_helper(
2347            "json",
2348            Box::new(
2349                |h: &handlebars::Helper,
2350                 _: &handlebars::Handlebars,
2351                 _: &handlebars::Context,
2352                 _: &mut handlebars::RenderContext,
2353                 out: &mut dyn handlebars::Output|
2354                 -> handlebars::HelperResult {
2355                    let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
2356                    out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
2357                    Ok(())
2358                },
2359            ),
2360        );
2361        let template = include_str!("templates/k6_crud_flow.hbs");
2362
2363        let custom_headers = self.parse_headers()?;
2364
2365        // Load parameter overrides if provided (for body configurations)
2366        let param_overrides = if let Some(params_file) = &self.params_file {
2367            TerminalReporter::print_progress("Loading parameter overrides...");
2368            let overrides = ParameterOverrides::from_file(params_file)?;
2369            TerminalReporter::print_success(&format!(
2370                "Loaded parameter overrides ({} operation-specific, {} defaults)",
2371                overrides.operations.len(),
2372                if overrides.defaults.is_empty() { 0 } else { 1 }
2373            ));
2374            Some(overrides)
2375        } else {
2376            None
2377        };
2378
2379        // Generate stages from scenario
2380        let duration_secs = Self::parse_duration(&self.duration)?;
2381        let scenario =
2382            LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
2383        let stages = scenario.generate_stages(duration_secs, self.vus);
2384
2385        // Resolve base path (CLI option takes priority over spec's servers URL)
2386        let api_base_path = self.resolve_base_path(parser);
2387        if let Some(ref bp) = api_base_path {
2388            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
2389        }
2390
2391        // Build headers JSON string for the template
2392        let mut all_headers = custom_headers.clone();
2393        if let Some(auth) = &self.auth {
2394            all_headers.insert("Authorization".to_string(), auth.clone());
2395        }
2396        let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
2397
2398        // Track all dynamic placeholders across all operations
2399        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
2400
2401        let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
2402            // Sanitize flow name for use as JavaScript variable and k6 metric names.
2403            // Use the metric-name sanitizer (caps at 112 chars + hash suffix) so
2404            // deeply nested flow names don't blow past k6's 128-char limit when
2405            // concatenated with `_step{i}_latency`. See issue #79.
2406            let sanitized_name = K6ScriptGenerator::sanitize_k6_metric_name(&f.name);
2407            serde_json::json!({
2408                "name": sanitized_name.clone(),  // Use sanitized name for variable names
2409                "display_name": f.name,          // Keep original for comments/display
2410                "base_path": f.base_path,
2411                "steps": f.steps.iter().enumerate().map(|(idx, s)| {
2412                    // Parse operation to get method and path
2413                    let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
2414                    let method_raw = if !parts.is_empty() {
2415                        parts[0].to_uppercase()
2416                    } else {
2417                        "GET".to_string()
2418                    };
2419                    let method = if !parts.is_empty() {
2420                        let m = parts[0].to_lowercase();
2421                        // k6 uses 'del' for DELETE
2422                        if m == "delete" { "del".to_string() } else { m }
2423                    } else {
2424                        "get".to_string()
2425                    };
2426                    let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
2427                    // Prepend API base path if configured
2428                    let path = if let Some(ref bp) = api_base_path {
2429                        format!("{}{}", bp, raw_path)
2430                    } else {
2431                        raw_path.to_string()
2432                    };
2433                    let is_get_or_head = method == "get" || method == "head";
2434                    // POST, PUT, PATCH typically have bodies
2435                    let has_body = matches!(method.as_str(), "post" | "put" | "patch");
2436
2437                    // Look up body from params file if available (use raw_path for matching)
2438                    let body_value = if has_body {
2439                        param_overrides.as_ref()
2440                            .map(|po| po.get_for_operation(None, &method_raw, raw_path))
2441                            .and_then(|oo| oo.body)
2442                            .unwrap_or_else(|| serde_json::json!({}))
2443                    } else {
2444                        serde_json::json!({})
2445                    };
2446
2447                    // Process body for dynamic placeholders like ${__VU}, ${__ITER}, etc.
2448                    let processed_body = DynamicParamProcessor::process_json_body(&body_value);
2449                    // Note: all_placeholders is captured by the closure but we can't mutate it directly
2450                    // We'll collect placeholders separately below
2451
2452                    // Also check for ${extracted.xxx} placeholders which need runtime substitution
2453                    let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
2454                    let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
2455
2456                    serde_json::json!({
2457                        "operation": s.operation,
2458                        "method": method,
2459                        "path": path,
2460                        "extract": s.extract,
2461                        "use_values": s.use_values,
2462                        "use_body": s.use_body,
2463                        "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
2464                        "inject_attacks": s.inject_attacks,
2465                        "attack_types": s.attack_types,
2466                        "description": s.description,
2467                        "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
2468                        "is_get_or_head": is_get_or_head,
2469                        "has_body": has_body,
2470                        "body": processed_body.value,
2471                        "body_is_dynamic": body_is_dynamic,
2472                        "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
2473                    })
2474                }).collect::<Vec<_>>(),
2475            })
2476        }).collect();
2477
2478        // Collect all placeholders from all steps
2479        for flow_data in &flows_data {
2480            if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
2481                for step in steps {
2482                    if let Some(placeholders_arr) =
2483                        step.get("_placeholders").and_then(|p| p.as_array())
2484                    {
2485                        for p_str in placeholders_arr {
2486                            if let Some(p_name) = p_str.as_str() {
2487                                // Parse placeholder from debug string
2488                                match p_name {
2489                                    "VU" => {
2490                                        all_placeholders.insert(DynamicPlaceholder::VU);
2491                                    }
2492                                    "Iteration" => {
2493                                        all_placeholders.insert(DynamicPlaceholder::Iteration);
2494                                    }
2495                                    "Timestamp" => {
2496                                        all_placeholders.insert(DynamicPlaceholder::Timestamp);
2497                                    }
2498                                    "UUID" => {
2499                                        all_placeholders.insert(DynamicPlaceholder::UUID);
2500                                    }
2501                                    "Random" => {
2502                                        all_placeholders.insert(DynamicPlaceholder::Random);
2503                                    }
2504                                    "Counter" => {
2505                                        all_placeholders.insert(DynamicPlaceholder::Counter);
2506                                    }
2507                                    "Date" => {
2508                                        all_placeholders.insert(DynamicPlaceholder::Date);
2509                                    }
2510                                    "VuIter" => {
2511                                        all_placeholders.insert(DynamicPlaceholder::VuIter);
2512                                    }
2513                                    _ => {}
2514                                }
2515                            }
2516                        }
2517                    }
2518                }
2519            }
2520        }
2521
2522        // Get required imports and globals based on placeholders used
2523        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
2524        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
2525
2526        // Build invalid data config if error injection is enabled
2527        let invalid_data_config = self.build_invalid_data_config();
2528        let error_injection_enabled = invalid_data_config.is_some();
2529        let error_rate = self.error_rate.unwrap_or(0.0);
2530        let error_types: Vec<String> = invalid_data_config
2531            .as_ref()
2532            .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
2533            .unwrap_or_default();
2534
2535        if error_injection_enabled {
2536            TerminalReporter::print_progress(&format!(
2537                "Error injection enabled ({}% rate)",
2538                (error_rate * 100.0) as u32
2539            ));
2540        }
2541
2542        // Check if security testing is enabled
2543        let security_testing_enabled = self.security_testing_enabled();
2544
2545        let data = serde_json::json!({
2546            "base_url": self.target,
2547            "flows": flows_data,
2548            "extract_fields": config.default_extract_fields,
2549            "duration_secs": duration_secs,
2550            "max_vus": self.vus,
2551            "auth_header": self.auth,
2552            "custom_headers": custom_headers,
2553            "skip_tls_verify": self.skip_tls_verify,
2554            // Add missing template fields
2555            "stages": stages.iter().map(|s| serde_json::json!({
2556                "duration": s.duration,
2557                "target": s.target,
2558            })).collect::<Vec<_>>(),
2559            "threshold_percentile": self.threshold_percentile,
2560            "threshold_ms": self.threshold_ms,
2561            "max_error_rate": self.max_error_rate,
2562            "abort_on_error": self.abort_on_error,
2563            "abort_on_error_rate": self.abort_on_error_rate,
2564            "headers": headers_json,
2565            "dynamic_imports": required_imports,
2566            "dynamic_globals": required_globals,
2567            "extracted_values_output_path": self
2568                .output
2569                .join("crud_flow_extracted_values.json")
2570                .to_string_lossy(),
2571            // Error injection settings
2572            "error_injection_enabled": error_injection_enabled,
2573            "error_rate": error_rate,
2574            "error_types": error_types,
2575            // Security testing settings
2576            "security_testing_enabled": security_testing_enabled,
2577            "has_custom_headers": !custom_headers.is_empty(),
2578        });
2579
2580        let mut script = handlebars
2581            .render_template(template, &data)
2582            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
2583
2584        // Enhance script with security testing support if enabled
2585        if security_testing_enabled {
2586            script = self.generate_enhanced_script(&script)?;
2587        }
2588
2589        // Validate the generated CRUD flow script
2590        TerminalReporter::print_progress("Validating CRUD flow script...");
2591        let validation_errors = K6ScriptGenerator::validate_script(&script);
2592        if !validation_errors.is_empty() {
2593            TerminalReporter::print_error("CRUD flow script validation failed");
2594            for error in &validation_errors {
2595                eprintln!("  {}", error);
2596            }
2597            return Err(BenchError::Other(format!(
2598                "CRUD flow script validation failed with {} error(s)",
2599                validation_errors.len()
2600            )));
2601        }
2602
2603        TerminalReporter::print_success("CRUD flow script generated");
2604
2605        // Write and execute script
2606        let script_path = if let Some(output) = &self.script_output {
2607            output.clone()
2608        } else {
2609            self.output.join("k6-crud-flow-script.js")
2610        };
2611
2612        if let Some(parent) = script_path.parent() {
2613            std::fs::create_dir_all(parent)?;
2614        }
2615        std::fs::write(&script_path, &script)?;
2616        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2617
2618        if self.generate_only {
2619            println!("\nScript generated successfully. Run it with:");
2620            println!("  k6 run {}", script_path.display());
2621            return Ok(());
2622        }
2623
2624        // Execute k6
2625        TerminalReporter::print_progress("Executing CRUD flow test...");
2626        let executor = K6Executor::new()?
2627            .with_local_ips(self.source_ips.join(","))
2628            .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
2629        std::fs::create_dir_all(&self.output)?;
2630
2631        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2632
2633        let duration_secs = Self::parse_duration(&self.duration)?;
2634        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
2635
2636        Ok(())
2637    }
2638
2639    /// Execute OpenAPI 3.0.0 conformance testing mode
2640    async fn execute_conformance_test(&self) -> Result<()> {
2641        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
2642        use crate::conformance::report::ConformanceReport;
2643        use crate::conformance::spec::ConformanceFeature;
2644
2645        TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
2646
2647        TerminalReporter::print_progress(CONFORMANCE_REPLACES_LOAD_ADVISORY);
2648
2649        // Parse category filter
2650        let categories = self.conformance_categories.as_ref().map(|cats_str| {
2651            cats_str
2652                .split(',')
2653                .filter_map(|s| {
2654                    let trimmed = s.trim();
2655                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
2656                        Some(canonical.to_string())
2657                    } else {
2658                        TerminalReporter::print_warning(&format!(
2659                            "Unknown conformance category: '{}'. Valid categories: {}",
2660                            trimmed,
2661                            ConformanceFeature::cli_category_names()
2662                                .iter()
2663                                .map(|(cli, _)| *cli)
2664                                .collect::<Vec<_>>()
2665                                .join(", ")
2666                        ));
2667                        None
2668                    }
2669                })
2670                .collect::<Vec<String>>()
2671        });
2672
2673        // Parse custom headers from "Key: Value" format
2674        let custom_headers: Vec<(String, String)> = self
2675            .conformance_headers
2676            .iter()
2677            .filter_map(|h| {
2678                let (name, value) = h.split_once(':')?;
2679                Some((name.trim().to_string(), value.trim().to_string()))
2680            })
2681            .collect();
2682
2683        if !custom_headers.is_empty() {
2684            TerminalReporter::print_progress(&format!(
2685                "Using {} custom header(s) for authentication",
2686                custom_headers.len()
2687            ));
2688        }
2689
2690        if self.conformance_delay_ms > 0 {
2691            TerminalReporter::print_progress(&format!(
2692                "Using {}ms delay between conformance requests",
2693                self.conformance_delay_ms
2694            ));
2695        }
2696
2697        // Ensure output dir exists so canonicalize works for the report path
2698        std::fs::create_dir_all(&self.output)?;
2699
2700        let config = ConformanceConfig {
2701            target_url: self.target.clone(),
2702            api_key: self.conformance_api_key.clone(),
2703            basic_auth: self.conformance_basic_auth.clone(),
2704            skip_tls_verify: self.skip_tls_verify,
2705            categories,
2706            base_path: self.base_path.clone(),
2707            custom_headers,
2708            output_dir: Some(self.output.clone()),
2709            all_operations: self.conformance_all_operations,
2710            custom_checks_file: self.conformance_custom.clone(),
2711            request_delay_ms: self.conformance_delay_ms,
2712            custom_filter: self.conformance_custom_filter.clone(),
2713            export_requests: self.export_requests,
2714            validate_requests: self.validate_requests,
2715        };
2716
2717        // Branch: spec-driven mode vs reference mode
2718        // Annotate operations if spec is provided (used by both native and k6 paths)
2719        // Round 18.1 — resolve the spec's base path so the self-test
2720        // path can prepend it to every URL. Pre-fix, self-test
2721        // ignored `--base-path /api` and hit the bare spec path,
2722        // returning 404 for every request on specs whose server is
2723        // proxied behind a base prefix.
2724        let mut resolved_base_path: Option<String> = None;
2725        let annotated_ops = if !self.spec.is_empty() {
2726            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
2727            let parser = SpecParser::from_file(&self.spec[0]).await?;
2728            resolved_base_path = self.resolve_base_path(&parser);
2729
2730            // Issue #79 round 12 — Srikanth ran `--conformance --operations "GET,POST"`
2731            // and saw DELETE/PATCH exercised anyway. Conformance silently ignored
2732            // the filter. Apply it (and `--exclude-operations`) the same way the
2733            // regular bench path does so users can scope the run.
2734            let mut operations = if let Some(filter) = &self.operations {
2735                parser.filter_operations(filter)?
2736            } else {
2737                parser.get_operations()
2738            };
2739            if let Some(exclude) = &self.exclude_operations {
2740                let before_count = operations.len();
2741                operations = parser.exclude_operations(operations, exclude)?;
2742                let excluded_count = before_count - operations.len();
2743                if excluded_count > 0 {
2744                    TerminalReporter::print_progress(&format!(
2745                        "Excluded {} operations matching '{}'",
2746                        excluded_count, exclude
2747                    ));
2748                }
2749            }
2750
2751            let annotated =
2752                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
2753                    &operations,
2754                    parser.spec(),
2755                );
2756            TerminalReporter::print_success(&format!(
2757                "Analyzed {} operations, found {} feature annotations",
2758                operations.len(),
2759                annotated.iter().map(|a| a.features.len()).sum::<usize>()
2760            ));
2761            Some(annotated)
2762        } else {
2763            None
2764        };
2765
2766        // Issue #79 round 13 (4) — `--conformance-self-test` replaces
2767        // the standard conformance run with a positive + per-category
2768        // negative driver that verifies the server actually rejects
2769        // bad requests with 4xx. Wires the spec-annotated operations
2770        // through `conformance::self_test::run_self_test` and prints
2771        // the resulting pass/fail matrix.
2772        if self.conformance_self_test {
2773            let Some(ops) = annotated_ops else {
2774                TerminalReporter::print_error(
2775                    "--conformance-self-test requires --spec; no operations to test",
2776                );
2777                return Ok(());
2778            };
2779            let cfg = crate::conformance::self_test::SelfTestConfig {
2780                target_url: self.target.clone(),
2781                skip_tls_verify: self.skip_tls_verify,
2782                timeout: std::time::Duration::from_secs(30),
2783                // `custom_headers` was already moved into the
2784                // `ConformanceConfig` above; re-derive from `self` so
2785                // we don't borrow it twice.
2786                extra_headers: self
2787                    .conformance_headers
2788                    .iter()
2789                    .filter_map(|h| {
2790                        let (n, v) = h.split_once(':')?;
2791                        Some((n.trim().to_string(), v.trim().to_string()))
2792                    })
2793                    .collect(),
2794                delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
2795                // Round 18.1 — honour `--base-path` (or the spec's
2796                // own first server prefix) so a deployment served
2797                // under a path-prefix doesn't 404 every positive.
2798                base_path: resolved_base_path.clone(),
2799                // Round 18.5 — GEODB multi-source-IP. Parse CLI IP
2800                // lists (malformed entries log a warning and are
2801                // dropped). Empty lists keep pre-18.5 behaviour.
2802                source_ips: parse_ip_list(&self.source_ips, "source-ip"),
2803                geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
2804                geo_source_headers: if self.geo_source_headers.is_empty() {
2805                    crate::conformance::self_test::default_geo_source_headers()
2806                } else {
2807                    self.geo_source_headers.clone()
2808                },
2809                // Round 23 (c-iii) — opt-in request/response capture.
2810                // Constructed here so the sink Arc outlives the run and
2811                // we can drain it for the JSONL write below.
2812                capture: if self.conformance_self_test_capture
2813                    || self.validate_response_schemas
2814                    || self.validate_requests
2815                {
2816                    // Schema validation reads the captured response
2817                    // body, so opt the user into capture implicitly
2818                    // when they ask for validation. The on-disk
2819                    // JSONL/HTML files only get written if the user
2820                    // also passed `--conformance-self-test-capture`.
2821                    // Round 56 (#79) — `--validate-requests` reads the
2822                    // captured *request* the same way, so enable capture
2823                    // for it too. Without this, `--validate-requests` on
2824                    // a self-test run had no in-memory requests to walk
2825                    // and silently wrote no violations file.
2826                    Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
2827                } else {
2828                    None
2829                },
2830                validate_response_schemas: self.validate_response_schemas,
2831                // Round 33 (#823) — basename of the spec, stamped on
2832                // every capture entry so the per-endpoint summary can
2833                // attribute rows back to the right spec on multi-spec
2834                // runs. Falls back to the full path string if the
2835                // basename can't be derived.
2836                spec_label: self.spec.first().map(|p| {
2837                    p.file_name()
2838                        .map(|s| s.to_string_lossy().into_owned())
2839                        .unwrap_or_else(|| p.to_string_lossy().into_owned())
2840                }),
2841                // Round 47 (#79) — always allocate the network-events
2842                // sink for self-test so the file is always written
2843                // (empty array when nothing failed — the cleanest
2844                // possible signal that connectivity stayed up). Caller
2845                // pays one Arc clone per probe, which is in the noise
2846                // next to the HTTP round-trip.
2847                network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
2848                current_iteration: 1,
2849            };
2850            let capture_sink = cfg.capture.clone();
2851            let network_events_sink = cfg.network_events.clone();
2852            TerminalReporter::print_progress(&format!(
2853                "Self-test mode: driving {} operations with positive + per-category negative cases",
2854                ops.len()
2855            ));
2856            // Round 47 (#79) — repeat the matrix per --conformance-
2857            // self-test-iterations / --conformance-self-test-duration.
2858            // Duration wins when both are set; iterations becomes the
2859            // floor so the matrix always runs at least the configured
2860            // number of times. Reports from each iteration are merged
2861            // by the per-category counter sum on the report itself.
2862            let target_iterations = self.conformance_self_test_iterations.max(1);
2863            let duration_budget = self
2864                .conformance_self_test_duration
2865                .as_ref()
2866                .map(|s| Self::parse_duration(s))
2867                .transpose()?
2868                .map(std::time::Duration::from_secs);
2869            let start = std::time::Instant::now();
2870            // Round 49 (#79) — Srikanth on 0.3.193: a 5m budget ran
2871            // 5:46 because the loop only checked the deadline AFTER a
2872            // full iteration completed. Pass the absolute deadline
2873            // into `run_self_test_with_deadline` so the runner can
2874            // break out mid-iteration the moment the budget elapses.
2875            // Iterations bound stays inclusive (so a duration-only run
2876            // doesn't loop forever on a fast spec) but stops EARLY when
2877            // the deadline hits first.
2878            let deadline = duration_budget.map(|d| start + d);
2879            // Round 49 — stamp `current_iteration` on cfg before each
2880            // pass so CaseCapture's `iteration` field carries the
2881            // loop counter (1-indexed).
2882            let mut cfg = cfg;
2883            cfg.current_iteration = 1;
2884            let mut report =
2885                crate::conformance::self_test::run_self_test_with_deadline(&ops, &cfg, deadline)
2886                    .await
2887                    .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2888            let mut iter_done: u32 = 1;
2889            loop {
2890                let by_iter = iter_done >= target_iterations;
2891                let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
2892                if by_iter && by_dur {
2893                    break;
2894                }
2895                cfg.current_iteration = iter_done.saturating_add(1);
2896                let next = crate::conformance::self_test::run_self_test_with_deadline(
2897                    &ops, &cfg, deadline,
2898                )
2899                .await
2900                .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
2901                report.merge_iteration(next);
2902                iter_done = iter_done.saturating_add(1);
2903            }
2904            if iter_done > 1 {
2905                TerminalReporter::print_progress(&format!(
2906                    "Self-test repeated {} iteration(s) ({:.1?} elapsed)",
2907                    iter_done,
2908                    start.elapsed(),
2909                ));
2910            }
2911            // Round 23 (c-iii) — drain the capture sink into a JSONL
2912            // file next to the JSON/HTML report. One CaseCapture per
2913            // line so the file is grep-able / streamable. Round 24
2914            // (d) — also emit a self-contained HTML viewer at
2915            // `conformance-self-test-requests.html` for users who
2916            // want to browse the capture without piping through `jq`.
2917            // Round 32 (#79 / Srikanth) — derive the per-endpoint
2918            // traffic summary from the same in-memory capture sink so
2919            // we don't re-parse the JSONL from disk later.
2920            let per_endpoint_summary: Vec<
2921                crate::conformance::per_endpoint_summary::PerEndpointSummary,
2922            >;
2923            if let Some(sink) = capture_sink {
2924                if let Ok(guard) = sink.lock() {
2925                    let jsonl_path = self.output.join("conformance-self-test-requests.jsonl");
2926                    let mut lines = String::with_capacity(guard.len() * 256);
2927                    for entry in guard.iter() {
2928                        if let Ok(line) = serde_json::to_string(entry) {
2929                            lines.push_str(&line);
2930                            lines.push('\n');
2931                        }
2932                    }
2933                    let _ = std::fs::write(&jsonl_path, lines);
2934                    let html_path = self.output.join("conformance-self-test-requests.html");
2935                    let html =
2936                        crate::conformance::capture_html::render_capture_html(guard.as_slice());
2937                    let _ = std::fs::write(&html_path, html);
2938
2939                    // Round 32 — per-endpoint summary derived once from
2940                    // the same slice. Written as a JSON sidecar for
2941                    // automation and spliced into the HTML report below.
2942                    per_endpoint_summary =
2943                        crate::conformance::per_endpoint_summary::build_summary(guard.as_slice());
2944                    let summary_path = self.output.join("conformance-per-endpoint.json");
2945                    if let Ok(json) = serde_json::to_string_pretty(&per_endpoint_summary) {
2946                        let _ = std::fs::write(&summary_path, json);
2947                        TerminalReporter::print_progress(&format!(
2948                            "Self-test request/response capture written to {} ({} entries) + {} + {}",
2949                            jsonl_path.display(),
2950                            guard.len(),
2951                            html_path.display(),
2952                            summary_path.display(),
2953                        ));
2954                    } else {
2955                        TerminalReporter::print_progress(&format!(
2956                            "Self-test request/response capture written to {} ({} entries) + {}",
2957                            jsonl_path.display(),
2958                            guard.len(),
2959                            html_path.display(),
2960                        ));
2961                    }
2962                } else {
2963                    per_endpoint_summary = Vec::new();
2964                }
2965            } else {
2966                per_endpoint_summary = Vec::new();
2967            }
2968            TerminalReporter::print_progress(&report.render_summary());
2969            // Round 47 (#79) — drain the self-test wire-level
2970            // network-events sink into `conformance-network-events.json`
2971            // so the user has the same grep-able file the native
2972            // executor's r46 path produces. Empty array when nothing
2973            // failed (the cleanest signal that connectivity stayed up
2974            // throughout the run).
2975            if let Some(sink) = network_events_sink {
2976                if let Ok(guard) = sink.lock() {
2977                    let path = self.output.join("conformance-network-events.json");
2978                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
2979                        let _ = std::fs::write(&path, json);
2980                        if guard.is_empty() {
2981                            TerminalReporter::print_progress(
2982                                "No wire-level network failures during self-test (file written empty)",
2983                            );
2984                        } else {
2985                            TerminalReporter::print_warning(&format!(
2986                                "Recorded {} wire-level network event(s) to {}",
2987                                guard.len(),
2988                                path.display()
2989                            ));
2990                        }
2991                    }
2992                }
2993            }
2994            // Persist the JSON report alongside the regular conformance
2995            // report so it's grep-able next to the buffer dump from the
2996            // admin endpoint.
2997            let json_path = self.output.join("conformance-self-test.json");
2998            if let Ok(json) = serde_json::to_string_pretty(&report) {
2999                let _ = std::fs::write(&json_path, json);
3000                TerminalReporter::print_progress(&format!(
3001                    "Self-test report written to {}",
3002                    json_path.display()
3003                ));
3004            }
3005            // Round 58 (#79) — write the "definite issues" sidecar so the
3006            // unambiguous problems are grep-able / automatable without eyeballing
3007            // the caught/missed rollup.
3008            let issues = report.definite_issues();
3009            let issues_path = self.output.join("conformance-definite-issues.json");
3010            if let Ok(json) = serde_json::to_string_pretty(&issues) {
3011                if std::fs::write(&issues_path, json).is_ok() && !issues.is_empty() {
3012                    TerminalReporter::print_warning(&format!(
3013                        "{} definite issue(s) — see {}",
3014                        issues.len(),
3015                        issues_path.display()
3016                    ));
3017                }
3018            }
3019            // Round 59 (#79) — owasp injection payloads the target accepted, so
3020            // a WAF tester can grep which URLs let which payloads through.
3021            let owasp_accepted = report.owasp_accepted_probes();
3022            if !owasp_accepted.is_empty() {
3023                let owasp_path = self.output.join("conformance-owasp-accepted.json");
3024                if let Ok(json) = serde_json::to_string_pretty(&owasp_accepted) {
3025                    if std::fs::write(&owasp_path, json).is_ok() {
3026                        TerminalReporter::print_warning(&format!(
3027                            "{} owasp injection probe(s) accepted by the target — see {}",
3028                            owasp_accepted.len(),
3029                            owasp_path.display()
3030                        ));
3031                    }
3032                }
3033            }
3034            // Round 18.1 — surface the "every positive failed with
3035            // the same status" case loudly. Without this, a user
3036            // who forgot `--base-path /api` saw 404 for every
3037            // request, but the per-category negative rollup looked
3038            // all-green (because 404 is in the 4xx range the
3039            // negatives expect). Now the run is correctly called
3040            // out as misconfigured before showing the (meaningless)
3041            // negative results.
3042            if let Some(status) = report.detect_target_misconfiguration() {
3043                let hint = match status {
3044                    404 => " Likely cause: spec paths don't match deployed routes — check --base-path and the spec's `servers` block.",
3045                    401 | 403 => " Likely cause: authentication header is missing or invalid — check --conformance-header.",
3046                    _ => "",
3047                };
3048                TerminalReporter::print_warning(&format!(
3049                    "Self-test misconfiguration: every positive case returned {status}.{hint} Negative results below are meaningless under this condition."
3050                ));
3051            } else if !report.all_passed() {
3052                TerminalReporter::print_warning(
3053                    "Self-test detected gaps — server let through at least one request that should have been a 4xx",
3054                );
3055            } else {
3056                TerminalReporter::print_success(
3057                    "Self-test passed — all positive cases accepted and all negative cases rejected",
3058                );
3059            }
3060            // Round 17.6 — emit a self-contained HTML report alongside
3061            // the JSON. Groups by category and surfaces the missed-
3062            // negative list directly so a user doesn't need to grep
3063            // through the JSON to find which routes failed which
3064            // checks. Optionally folds in a round-17.4 spec audit
3065            // report if one exists in the same output directory.
3066            let html_path = self.output.join("conformance-report.html");
3067            let audit_path = self.output.join("conformance-spec-audit.json");
3068            let audit_value = std::fs::read_to_string(&audit_path)
3069                .ok()
3070                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
3071            // Round 21.1 — `--report-missed-cap N` lets the user
3072            // override the default 200-row HTML drill-down cap.
3073            // `--report-missed-cap 0` maps to `None` (no cap; show
3074            // everything). The JSON report always has the full set.
3075            let render_opts = crate::conformance::report_html::RenderOptions {
3076                missed_cap: match self.report_missed_cap {
3077                    Some(0) => None,
3078                    Some(n) => Some(n as usize),
3079                    None => Some(200),
3080                },
3081            };
3082            let mut html = crate::conformance::report_html::render_html_with_options(
3083                &report,
3084                audit_value.as_ref(),
3085                &render_opts,
3086            );
3087            // Round 32 (#79 / Srikanth) — splice the per-endpoint
3088            // summary just before the closing `</body>` so it lands at
3089            // the bottom of the report. Empty summary renders as an
3090            // empty string so we don't even introduce an extra newline
3091            // when there were no captures.
3092            let summary_section = crate::conformance::per_endpoint_summary::render_html_section(
3093                &per_endpoint_summary,
3094            );
3095            if !summary_section.is_empty() {
3096                if let Some(idx) = html.rfind("</body>") {
3097                    html.insert_str(idx, &summary_section);
3098                } else {
3099                    html.push_str(&summary_section);
3100                }
3101            }
3102            if std::fs::write(&html_path, html).is_ok() {
3103                TerminalReporter::print_progress(&format!(
3104                    "HTML report written to {}",
3105                    html_path.display()
3106                ));
3107            }
3108
3109            // Round 56 (#79) — Srikanth on 0.3.203: "parameter violations
3110            // are still absent from the logs." Root cause: the SINGLE-target
3111            // self-test returned here without ever walking the emitted
3112            // requests. Only the r49 multi-target self-test path (the
3113            // `--targets-file` workflow) called the validator. Mirror that
3114            // wiring here so a plain single-target self-test also writes
3115            // `conformance-request-violations.json`. The validator reads the
3116            // capture we just drained to the JSONL; my r56 change to
3117            // `request_validator.rs` records each `parameters:*` negative as
3118            // a `parameter_negative_probe` even when the emitted request is
3119            // spec-valid (so the three parameter probes stop being silent).
3120            if self.validate_requests && !self.spec.is_empty() {
3121                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3122                    &self.spec,
3123                    &self.output,
3124                    self.base_path.as_deref(),
3125                )
3126                .await?;
3127                if n > 0 {
3128                    TerminalReporter::print_warning(&format!(
3129                        "{} emitted request(s) recorded against the spec — see conformance-request-violations.json",
3130                        n
3131                    ));
3132                }
3133            }
3134            return Ok(());
3135        }
3136
3137        // Request validation against OpenAPI spec (if --validate-requests is set)
3138        if self.validate_requests && !self.spec.is_empty() {
3139            TerminalReporter::print_progress("Validating requests against OpenAPI spec...");
3140            let violation_count = crate::conformance::request_validator::run_request_validation(
3141                &self.spec,
3142                self.conformance_custom.as_deref(),
3143                self.base_path.as_deref(),
3144                &self.output,
3145            )
3146            .await?;
3147            if violation_count > 0 {
3148                TerminalReporter::print_warning(&format!(
3149                    "{} request validation violation(s) found — see conformance-request-violations.json",
3150                    violation_count
3151                ));
3152            } else {
3153                TerminalReporter::print_success("All requests conform to the OpenAPI spec");
3154            }
3155        }
3156
3157        // If generate-only OR --use-k6, use the k6 script generation path
3158        if self.generate_only || self.use_k6 {
3159            let script = if let Some(annotated) = &annotated_ops {
3160                let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
3161                    config,
3162                    annotated.clone(),
3163                );
3164                let op_count = gen.operation_count();
3165                let (script, check_count) = gen.generate()?;
3166                TerminalReporter::print_success(&format!(
3167                    "Conformance: {} operations analyzed, {} unique checks generated",
3168                    op_count, check_count
3169                ));
3170                script
3171            } else {
3172                let generator = ConformanceGenerator::new(config);
3173                generator.generate()?
3174            };
3175
3176            let script_path = self.output.join("k6-conformance.js");
3177            std::fs::write(&script_path, &script).map_err(|e| {
3178                BenchError::Other(format!("Failed to write conformance script: {}", e))
3179            })?;
3180            TerminalReporter::print_success(&format!(
3181                "Conformance script generated: {}",
3182                script_path.display()
3183            ));
3184
3185            if self.generate_only {
3186                println!("\nScript generated. Run with:");
3187                println!("  k6 run {}", script_path.display());
3188                return Ok(());
3189            }
3190
3191            // --use-k6: execute via k6
3192            if !K6Executor::is_k6_installed() {
3193                TerminalReporter::print_error("k6 is not installed");
3194                TerminalReporter::print_warning(
3195                    "Install k6 from: https://k6.io/docs/get-started/installation/",
3196                );
3197                return Err(BenchError::K6NotFound);
3198            }
3199
3200            TerminalReporter::print_progress("Running conformance tests via k6...");
3201            let executor = K6Executor::new()?
3202                .with_local_ips(self.source_ips.join(","))
3203                .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3204            executor.execute(&script_path, Some(&self.output), self.verbose).await?;
3205
3206            let report_path = self.output.join("conformance-report.json");
3207            if report_path.exists() {
3208                let report = ConformanceReport::from_file(&report_path)?;
3209                report.print_report_with_options(self.conformance_all_operations);
3210                self.save_conformance_report(&report, &report_path)?;
3211            } else {
3212                TerminalReporter::print_warning(
3213                    "Conformance report not generated (k6 handleSummary may not have run)",
3214                );
3215            }
3216
3217            // Round 44 (#79) — Srikanth on 0.3.188: "Any reason why
3218            // validate-requests in mockforge client are not catching
3219            // all this query param or body params or path params
3220            // violation issues and record in conformance-request-
3221            // failure logs?" The custom-YAML validator only checks
3222            // the YAML shape at config time. Now, when both
3223            // `--validate-requests` and `--export-requests` are set,
3224            // also walk the emitted `conformance-requests.json` and
3225            // validate each actual wire-level request against the
3226            // spec. Violations are appended to
3227            // `conformance-request-violations.json`.
3228            if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3229                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3230                    &self.spec,
3231                    &self.output,
3232                    self.base_path.as_deref(),
3233                )
3234                .await?;
3235                if n > 0 {
3236                    TerminalReporter::print_warning(&format!(
3237                        "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3238                        n
3239                    ));
3240                }
3241            }
3242
3243            return Ok(());
3244        }
3245
3246        // Default: Native Rust executor (no k6 dependency)
3247        TerminalReporter::print_progress("Running conformance tests (native executor)...");
3248
3249        let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3250
3251        // Round 39 (#79) — when the user passed `--conformance-custom`
3252        // WITHOUT `--spec` and without `--conformance-self-test`, fire
3253        // only the YAML's checks. The built-in 47 reference checks
3254        // (`param:path:string`, etc.) hit `/conformance/...` paths that
3255        // do not exist on a real target, so a custom-only run against
3256        // a remote API produced a flood of irrelevant 404s in the
3257        // request log. Srikanth on 0.3.183: "In the exported request I
3258        // see it is sending request to api/conformance/params/hello
3259        // and some other URLs".
3260        let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3261        executor = if let Some(annotated) = &annotated_ops {
3262            executor.with_spec_driven_checks(annotated)
3263        } else if custom_only {
3264            executor
3265        } else {
3266            executor.with_reference_checks()
3267        };
3268        executor = executor.with_custom_checks()?;
3269
3270        TerminalReporter::print_success(&format!(
3271            "Executing {} conformance checks...",
3272            executor.check_count()
3273        ));
3274
3275        let report = executor.execute().await?;
3276        report.print_report_with_options(self.conformance_all_operations);
3277
3278        // Save failure details to a separate file for easy debugging
3279        let failure_details = report.failure_details();
3280        if !failure_details.is_empty() {
3281            let details_path = self.output.join("conformance-failure-details.json");
3282            if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3283                let _ = std::fs::write(&details_path, json);
3284                TerminalReporter::print_success(&format!(
3285                    "Failure details saved to: {}",
3286                    details_path.display()
3287                ));
3288            }
3289        }
3290
3291        // Save report
3292        let report_path = self.output.join("conformance-report.json");
3293        let report_json = serde_json::to_string_pretty(&report.to_json())
3294            .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3295        std::fs::write(&report_path, &report_json)
3296            .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3297        TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
3298
3299        self.save_conformance_report(&report, &report_path)?;
3300
3301        // Round 45 (#79) — Srikanth on 0.3.189: "I am still not seeing
3302        // any conformance failure logs or conformance-request logs are
3303        // also not capturing any failures info." His command does NOT
3304        // pass `--use-k6`, so the round-44 wiring (which only ran on
3305        // the k6 branch) never fired. Mirror the same retrospective
3306        // pass here: when both `--validate-requests` and
3307        // `--export-requests` are set, walk the native executor's
3308        // freshly-written `conformance-requests.json` and validate
3309        // each emitted request against the spec. Violations are
3310        // appended to `conformance-request-violations.json`.
3311        if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3312            let n =
3313                crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3314                    &self.spec,
3315                    &self.output,
3316                    self.base_path.as_deref(),
3317                )
3318                .await?;
3319            if n > 0 {
3320                TerminalReporter::print_warning(&format!(
3321                    "{} emitted request(s) violated the spec — see conformance-request-violations.json",
3322                    n
3323                ));
3324            }
3325        }
3326
3327        Ok(())
3328    }
3329
3330    /// Save conformance report in the requested format (SARIF or JSON copy)
3331    fn save_conformance_report(
3332        &self,
3333        report: &crate::conformance::report::ConformanceReport,
3334        report_path: &Path,
3335    ) -> Result<()> {
3336        if self.conformance_report_format == "sarif" {
3337            use crate::conformance::sarif::ConformanceSarifReport;
3338            ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
3339            TerminalReporter::print_success(&format!(
3340                "SARIF report saved to: {}",
3341                self.conformance_report.display()
3342            ));
3343        } else if self.conformance_report != *report_path {
3344            std::fs::copy(report_path, &self.conformance_report)?;
3345            TerminalReporter::print_success(&format!(
3346                "Report saved to: {}",
3347                self.conformance_report.display()
3348            ));
3349        }
3350        Ok(())
3351    }
3352
3353    /// Round 48 (#79) — Srikanth on 0.3.192: "I ran following commands
3354    /// to test conformance-sef-test duration, but the test ended
3355    /// immediately" with `--targets-file vs_list1.json`. The multi-
3356    /// target dispatch returned before reaching the round-47 self-test
3357    /// iteration loop. This helper runs the self-test driver against
3358    /// every target listed in `targets_file` honouring the same
3359    /// `--conformance-self-test-iterations` and `--conformance-self-
3360    /// test-duration` knobs the single-target path got. One self-test
3361    /// report file per target plus a `conformance-network-events.json`
3362    /// per target so a user can attribute wire failures back to the
3363    /// target they happened against.
3364    async fn execute_multi_target_self_test(&self, targets_file: &Path) -> Result<()> {
3365        use crate::conformance::self_test::SelfTestConfig;
3366
3367        TerminalReporter::print_progress("Multi-target conformance self-test mode");
3368        let targets = parse_targets_file(targets_file)?;
3369        if targets.is_empty() {
3370            return Err(BenchError::Other("No targets found in file".to_string()));
3371        }
3372        TerminalReporter::print_success(&format!("Loaded {} target(s)", targets.len()));
3373
3374        // Spec is shared across targets — load once.
3375        let annotated_ops = if !self.spec.is_empty() {
3376            let parser = SpecParser::from_file(&self.spec[0]).await?;
3377            let operations = parser.get_operations();
3378            Some(
3379                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3380                    &operations,
3381                    parser.spec(),
3382                ),
3383            )
3384        } else {
3385            return Err(BenchError::Other("--conformance-self-test requires --spec".to_string()));
3386        };
3387        let Some(ops) = annotated_ops else {
3388            unreachable!()
3389        };
3390
3391        std::fs::create_dir_all(&self.output)?;
3392        let resolved_base_path = self.base_path.clone();
3393        let target_iterations = self.conformance_self_test_iterations.max(1);
3394        let duration_budget = self
3395            .conformance_self_test_duration
3396            .as_ref()
3397            .map(|s| Self::parse_duration(s))
3398            .transpose()?
3399            .map(std::time::Duration::from_secs);
3400
3401        for (idx, target) in targets.iter().enumerate() {
3402            let target_dir = self.output.join(format!("target_{}", idx));
3403            std::fs::create_dir_all(&target_dir)?;
3404            TerminalReporter::print_progress(&format!(
3405                "[target {}/{}] {}",
3406                idx + 1,
3407                targets.len(),
3408                target.url
3409            ));
3410
3411            let merged_headers: Vec<(String, String)> = self
3412                .conformance_headers
3413                .iter()
3414                .filter_map(|h| {
3415                    let (n, v) = h.split_once(':')?;
3416                    Some((n.trim().to_string(), v.trim().to_string()))
3417                })
3418                .collect();
3419
3420            let cfg = SelfTestConfig {
3421                target_url: target.url.clone(),
3422                skip_tls_verify: self.skip_tls_verify,
3423                timeout: std::time::Duration::from_secs(30),
3424                extra_headers: merged_headers,
3425                delay_between_requests: std::time::Duration::from_millis(self.conformance_delay_ms),
3426                base_path: resolved_base_path.clone(),
3427                source_ips: parse_ip_list(&self.source_ips, "source-ip"),
3428                geo_source_ips: parse_ip_list(&self.geo_source_ips, "geo-source-ip"),
3429                geo_source_headers: if self.geo_source_headers.is_empty() {
3430                    crate::conformance::self_test::default_geo_source_headers()
3431                } else {
3432                    self.geo_source_headers.clone()
3433                },
3434                capture: if self.conformance_self_test_capture
3435                    || self.validate_response_schemas
3436                    || self.validate_requests
3437                {
3438                    // Round 56 (#79) — mirror the single-target path: the
3439                    // per-target request validator needs the captured
3440                    // requests, so `--validate-requests` implies capture.
3441                    Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
3442                } else {
3443                    None
3444                },
3445                validate_response_schemas: self.validate_response_schemas,
3446                spec_label: self.spec.first().map(|p| {
3447                    p.file_name()
3448                        .map(|s| s.to_string_lossy().into_owned())
3449                        .unwrap_or_else(|| p.to_string_lossy().into_owned())
3450                }),
3451                network_events: Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))),
3452                current_iteration: 1,
3453            };
3454            let capture_sink = cfg.capture.clone();
3455            let network_events_sink = cfg.network_events.clone();
3456
3457            let start = std::time::Instant::now();
3458            // Round 49 — pass an absolute deadline down so the loop
3459            // can break out mid-iteration once the budget elapses
3460            // instead of overshooting by a full pass.
3461            let deadline = duration_budget.map(|d| start + d);
3462            // Round 49 — stamp `current_iteration` on cfg before each
3463            // pass so CaseCapture's `iteration` field carries the
3464            // loop counter (1-indexed).
3465            let mut cfg = cfg;
3466            cfg.current_iteration = 1;
3467            let mut report =
3468                crate::conformance::self_test::run_self_test_with_deadline(&ops, &cfg, deadline)
3469                    .await
3470                    .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3471            let mut iter_done: u32 = 1;
3472            loop {
3473                let by_iter = iter_done >= target_iterations;
3474                let by_dur = duration_budget.map(|d| start.elapsed() >= d).unwrap_or(true);
3475                if by_iter && by_dur {
3476                    break;
3477                }
3478                cfg.current_iteration = iter_done.saturating_add(1);
3479                let next = crate::conformance::self_test::run_self_test_with_deadline(
3480                    &ops, &cfg, deadline,
3481                )
3482                .await
3483                .map_err(|e| BenchError::Other(format!("self-test client error: {e}")))?;
3484                report.merge_iteration(next);
3485                iter_done = iter_done.saturating_add(1);
3486            }
3487            if iter_done > 1 {
3488                TerminalReporter::print_progress(&format!(
3489                    "  ran {} iteration(s) in {:.1?}",
3490                    iter_done,
3491                    start.elapsed(),
3492                ));
3493            }
3494
3495            // Drain the per-target sinks.
3496            if let Some(sink) = capture_sink {
3497                if let Ok(guard) = sink.lock() {
3498                    let jsonl = target_dir.join("conformance-self-test-requests.jsonl");
3499                    let mut lines = String::with_capacity(guard.len() * 256);
3500                    for entry in guard.iter() {
3501                        if let Ok(line) = serde_json::to_string(entry) {
3502                            lines.push_str(&line);
3503                            lines.push('\n');
3504                        }
3505                    }
3506                    let _ = std::fs::write(&jsonl, lines);
3507                }
3508            }
3509            if let Some(sink) = network_events_sink {
3510                if let Ok(guard) = sink.lock() {
3511                    let path = target_dir.join("conformance-network-events.json");
3512                    if let Ok(json) = serde_json::to_string_pretty(&*guard) {
3513                        let _ = std::fs::write(&path, json);
3514                        if !guard.is_empty() {
3515                            TerminalReporter::print_warning(&format!(
3516                                "  recorded {} wire-level network event(s)",
3517                                guard.len()
3518                            ));
3519                        }
3520                    }
3521                }
3522            }
3523
3524            let json_path = target_dir.join("conformance-self-test.json");
3525            if let Ok(json) = serde_json::to_string_pretty(&report) {
3526                let _ = std::fs::write(&json_path, json);
3527            }
3528            // Round 58 (#79) — per-target "definite issues" sidecar (mirror of
3529            // the single-target path).
3530            let issues = report.definite_issues();
3531            if let Ok(json) = serde_json::to_string_pretty(&issues) {
3532                let issues_path = target_dir.join("conformance-definite-issues.json");
3533                if std::fs::write(&issues_path, json).is_ok() && !issues.is_empty() {
3534                    TerminalReporter::print_warning(&format!(
3535                        "  {} definite issue(s) — see {}",
3536                        issues.len(),
3537                        issues_path.display()
3538                    ));
3539                }
3540            }
3541            // Round 59 (#79) — per-target owasp-accepted sidecar (mirror).
3542            let owasp_accepted = report.owasp_accepted_probes();
3543            if !owasp_accepted.is_empty() {
3544                if let Ok(json) = serde_json::to_string_pretty(&owasp_accepted) {
3545                    let owasp_path = target_dir.join("conformance-owasp-accepted.json");
3546                    if std::fs::write(&owasp_path, json).is_ok() {
3547                        TerminalReporter::print_warning(&format!(
3548                            "  {} owasp injection probe(s) accepted by the target — see {}",
3549                            owasp_accepted.len(),
3550                            owasp_path.display()
3551                        ));
3552                    }
3553                }
3554            }
3555            TerminalReporter::print_progress(&report.render_summary());
3556
3557            // Round 49 (#79) — Srikanth on 0.3.193: "I am not seeing
3558            // any violation requests logs when running [self-test
3559            // + --targets-file]". `validate_emitted_requests` was
3560            // only wired into the bench-export path; self-test
3561            // writes its captures to `conformance-self-test-
3562            // requests.jsonl` instead. The validator now reads that
3563            // file too (see the JSONL branch in request_validator.rs),
3564            // so we just need to invoke it here per-target.
3565            if self.validate_requests {
3566                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3567                    &self.spec,
3568                    &target_dir,
3569                    self.base_path.as_deref(),
3570                )
3571                .await?;
3572                if n > 0 {
3573                    TerminalReporter::print_warning(&format!(
3574                        "  {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3575                        n,
3576                        target_dir.display(),
3577                    ));
3578                }
3579            }
3580        }
3581
3582        Ok(())
3583    }
3584
3585    /// Execute conformance tests against multiple targets from a targets file.
3586    ///
3587    /// Uses the native `NativeConformanceExecutor` (no k6 dependency). Targets are
3588    /// tested sequentially to avoid overwhelming them, and per-target headers from
3589    /// the targets file are merged with the base `--conformance-header` headers.
3590    async fn execute_multi_target_conformance(&self, targets_file: &Path) -> Result<()> {
3591        use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
3592        use crate::conformance::report::ConformanceReport;
3593        use crate::conformance::spec::ConformanceFeature;
3594
3595        TerminalReporter::print_progress("Multi-target OpenAPI 3.0.0 Conformance Testing Mode");
3596
3597        // Parse targets file
3598        TerminalReporter::print_progress("Parsing targets file...");
3599        let targets = parse_targets_file(targets_file)?;
3600        let num_targets = targets.len();
3601        TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
3602
3603        if targets.is_empty() {
3604            return Err(BenchError::Other("No targets found in file".to_string()));
3605        }
3606
3607        TerminalReporter::print_progress(CONFORMANCE_REPLACES_LOAD_ADVISORY);
3608
3609        // Parse category filter (shared across all targets)
3610        let categories = self.conformance_categories.as_ref().map(|cats_str| {
3611            cats_str
3612                .split(',')
3613                .filter_map(|s| {
3614                    let trimmed = s.trim();
3615                    if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
3616                        Some(canonical.to_string())
3617                    } else {
3618                        TerminalReporter::print_warning(&format!(
3619                            "Unknown conformance category: '{}'. Valid categories: {}",
3620                            trimmed,
3621                            ConformanceFeature::cli_category_names()
3622                                .iter()
3623                                .map(|(cli, _)| *cli)
3624                                .collect::<Vec<_>>()
3625                                .join(", ")
3626                        ));
3627                        None
3628                    }
3629                })
3630                .collect::<Vec<String>>()
3631        });
3632
3633        // Parse base custom headers from --conformance-header flags
3634        let base_custom_headers: Vec<(String, String)> = self
3635            .conformance_headers
3636            .iter()
3637            .filter_map(|h| {
3638                let (name, value) = h.split_once(':')?;
3639                Some((name.trim().to_string(), value.trim().to_string()))
3640            })
3641            .collect();
3642
3643        if !base_custom_headers.is_empty() {
3644            TerminalReporter::print_progress(&format!(
3645                "Using {} base custom header(s) for authentication",
3646                base_custom_headers.len()
3647            ));
3648        }
3649
3650        // Load spec once if provided (shared across all targets)
3651        let annotated_ops = if !self.spec.is_empty() {
3652            TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
3653            let parser = SpecParser::from_file(&self.spec[0]).await?;
3654            let operations = parser.get_operations();
3655            let annotated =
3656                crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
3657                    &operations,
3658                    parser.spec(),
3659                );
3660            TerminalReporter::print_success(&format!(
3661                "Analyzed {} operations, found {} feature annotations",
3662                operations.len(),
3663                annotated.iter().map(|a| a.features.len()).sum::<usize>()
3664            ));
3665            Some(annotated)
3666        } else {
3667            None
3668        };
3669
3670        // Ensure output dir exists
3671        std::fs::create_dir_all(&self.output)?;
3672
3673        // Collect per-target results for the summary
3674        struct TargetResult {
3675            url: String,
3676            passed: usize,
3677            failed: usize,
3678            elapsed: std::time::Duration,
3679            report_json: serde_json::Value,
3680            owasp_coverage: Vec<crate::conformance::report::OwaspCoverageEntry>,
3681        }
3682
3683        let mut target_results: Vec<TargetResult> = Vec::with_capacity(num_targets);
3684        let total_start = std::time::Instant::now();
3685
3686        for (idx, target) in targets.iter().enumerate() {
3687            tracing::info!(
3688                "Running conformance tests against target {}/{}: {}",
3689                idx + 1,
3690                num_targets,
3691                target.url
3692            );
3693            TerminalReporter::print_progress(&format!(
3694                "\n--- Target {}/{}: {} ---",
3695                idx + 1,
3696                num_targets,
3697                target.url
3698            ));
3699
3700            // Merge base headers with per-target headers
3701            let mut merged_headers = base_custom_headers.clone();
3702            if let Some(ref target_headers) = target.headers {
3703                for (name, value) in target_headers {
3704                    // Per-target headers override base headers with the same name
3705                    if let Some(existing) = merged_headers.iter_mut().find(|(n, _)| n == name) {
3706                        existing.1 = value.clone();
3707                    } else {
3708                        merged_headers.push((name.clone(), value.clone()));
3709                    }
3710                }
3711            }
3712            // Add auth header if present on target
3713            if let Some(ref auth) = target.auth {
3714                if let Some(existing) =
3715                    merged_headers.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case("Authorization"))
3716                {
3717                    existing.1 = auth.clone();
3718                } else {
3719                    merged_headers.push(("Authorization".to_string(), auth.clone()));
3720                }
3721            }
3722
3723            // Per-target output dir (used by both native and k6 paths).
3724            // Created before the config so we can point the k6 script's
3725            // handleSummary at the per-target directory rather than the shared
3726            // parent output dir (otherwise every target would overwrite the
3727            // same conformance-report.json).
3728            let target_dir = self.output.join(format!("target_{}", idx));
3729            std::fs::create_dir_all(&target_dir)?;
3730
3731            let config = ConformanceConfig {
3732                target_url: target.url.clone(),
3733                api_key: self.conformance_api_key.clone(),
3734                basic_auth: self.conformance_basic_auth.clone(),
3735                skip_tls_verify: self.skip_tls_verify,
3736                categories: categories.clone(),
3737                base_path: self.base_path.clone(),
3738                custom_headers: merged_headers,
3739                output_dir: Some(target_dir.clone()),
3740                all_operations: self.conformance_all_operations,
3741                custom_checks_file: self.conformance_custom.clone(),
3742                request_delay_ms: self.conformance_delay_ms,
3743                custom_filter: self.conformance_custom_filter.clone(),
3744                export_requests: self.export_requests,
3745                validate_requests: self.validate_requests,
3746            };
3747
3748            let target_start = std::time::Instant::now();
3749            let report = if self.use_k6 {
3750                if !K6Executor::is_k6_installed() {
3751                    TerminalReporter::print_error("k6 is not installed");
3752                    TerminalReporter::print_warning(
3753                        "Install k6 from: https://k6.io/docs/get-started/installation/",
3754                    );
3755                    return Err(BenchError::K6NotFound);
3756                }
3757
3758                let script = if let Some(ref annotated) = annotated_ops {
3759                    let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
3760                        config.clone(),
3761                        annotated.clone(),
3762                    );
3763                    let (script, _check_count) = gen.generate()?;
3764                    script
3765                } else {
3766                    let generator = ConformanceGenerator::new(config.clone());
3767                    generator.generate()?
3768                };
3769
3770                let script_path = target_dir.join("k6-conformance.js");
3771                std::fs::write(&script_path, &script).map_err(|e| {
3772                    BenchError::Other(format!("Failed to write conformance script: {}", e))
3773                })?;
3774                TerminalReporter::print_success(&format!(
3775                    "Conformance script generated: {}",
3776                    script_path.display()
3777                ));
3778
3779                TerminalReporter::print_progress(&format!(
3780                    "Running conformance tests via k6 against {}...",
3781                    target.url
3782                ));
3783                let k6 = K6Executor::new()?
3784                    .with_local_ips(self.source_ips.join(","))
3785                    .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
3786                // Unique k6 API port per target to avoid collisions.
3787                let api_port = 6565u16.saturating_add(idx as u16);
3788                k6.execute_with_port(&script_path, Some(&target_dir), self.verbose, Some(api_port))
3789                    .await?;
3790
3791                let report_path = target_dir.join("conformance-report.json");
3792                if report_path.exists() {
3793                    ConformanceReport::from_file(&report_path)?
3794                } else {
3795                    TerminalReporter::print_warning(&format!(
3796                        "Conformance report not generated for target {} (k6 handleSummary may not have run)",
3797                        target.url
3798                    ));
3799                    continue;
3800                }
3801            } else {
3802                let mut executor =
3803                    crate::conformance::executor::NativeConformanceExecutor::new(config)?;
3804
3805                // Round 39 (#79) — see custom_only comment above; same
3806                // logic applied to the multi-target branch.
3807                let custom_only = annotated_ops.is_none() && self.conformance_custom.is_some();
3808                executor = if let Some(ref annotated) = annotated_ops {
3809                    executor.with_spec_driven_checks(annotated)
3810                } else if custom_only {
3811                    executor
3812                } else {
3813                    executor.with_reference_checks()
3814                };
3815                executor = executor.with_custom_checks()?;
3816
3817                TerminalReporter::print_success(&format!(
3818                    "Executing {} conformance checks against {}...",
3819                    executor.check_count(),
3820                    target.url
3821                ));
3822
3823                executor.execute().await?
3824            };
3825            let target_elapsed = target_start.elapsed();
3826
3827            let report_json = report.to_json();
3828
3829            // Extract pass/fail from the summary in the JSON
3830            let passed = report_json["summary"]["passed"].as_u64().unwrap_or(0) as usize;
3831            let failed = report_json["summary"]["failed"].as_u64().unwrap_or(0) as usize;
3832            let total_checks = passed + failed;
3833            let rate = if total_checks == 0 {
3834                0.0
3835            } else {
3836                (passed as f64 / total_checks as f64) * 100.0
3837            };
3838
3839            TerminalReporter::print_success(&format!(
3840                "Target {}: {}/{} passed ({:.1}%) in {:.1}s",
3841                target.url,
3842                passed,
3843                total_checks,
3844                rate,
3845                target_elapsed.as_secs_f64()
3846            ));
3847
3848            // Save per-target report (target_dir created above)
3849            let target_report_path = target_dir.join("conformance-report.json");
3850            let report_str = serde_json::to_string_pretty(&report_json)
3851                .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
3852            std::fs::write(&target_report_path, &report_str)
3853                .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
3854
3855            // Save failure details if any
3856            let failure_details = report.failure_details();
3857            if !failure_details.is_empty() {
3858                let details_path = target_dir.join("conformance-failure-details.json");
3859                if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
3860                    let _ = std::fs::write(&details_path, json);
3861                }
3862            }
3863
3864            // Round 45 (#79) — Srikanth on 0.3.189: `conformance-request-
3865            // violations.json` was never being written in his
3866            // multi-target self-test run. The round-44 wiring sat on
3867            // the single-target branch only. Mirror it here, per
3868            // target_dir, so the multi-target case (his typical
3869            // workflow) actually surfaces wire-level violations.
3870            if self.validate_requests && self.export_requests && !self.spec.is_empty() {
3871                let n = crate::conformance::request_validator::validate_emitted_requests_with_base_path(
3872                    &self.spec,
3873                    &target_dir,
3874                    self.base_path.as_deref(),
3875                )
3876                .await?;
3877                if n > 0 {
3878                    TerminalReporter::print_warning(&format!(
3879                        "Target {}: {} emitted request(s) violated the spec — see {}/conformance-request-violations.json",
3880                        target.url,
3881                        n,
3882                        target_dir.display()
3883                    ));
3884                }
3885            }
3886
3887            // Compute OWASP coverage for this target
3888            let owasp_coverage = report.owasp_coverage_data();
3889
3890            target_results.push(TargetResult {
3891                url: target.url.clone(),
3892                passed,
3893                failed,
3894                elapsed: target_elapsed,
3895                report_json,
3896                owasp_coverage,
3897            });
3898        }
3899
3900        let total_elapsed = total_start.elapsed();
3901
3902        // Print summary table
3903        println!("\n{}", "=".repeat(80));
3904        println!("  Multi-Target Conformance Summary");
3905        println!("{}", "=".repeat(80));
3906        println!(
3907            "  {:<40} {:>8} {:>8} {:>8} {:>8}",
3908            "Target URL", "Passed", "Failed", "Rate", "Time"
3909        );
3910        println!("  {}", "-".repeat(76));
3911
3912        let mut total_passed = 0usize;
3913        let mut total_failed = 0usize;
3914
3915        for result in &target_results {
3916            let total_checks = result.passed + result.failed;
3917            let rate = if total_checks == 0 {
3918                0.0
3919            } else {
3920                (result.passed as f64 / total_checks as f64) * 100.0
3921            };
3922
3923            // Truncate long URLs for display
3924            let display_url = if result.url.len() > 38 {
3925                format!("{}...", &result.url[..35])
3926            } else {
3927                result.url.clone()
3928            };
3929
3930            println!(
3931                "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3932                display_url,
3933                result.passed,
3934                result.failed,
3935                rate,
3936                result.elapsed.as_secs_f64()
3937            );
3938
3939            total_passed += result.passed;
3940            total_failed += result.failed;
3941        }
3942
3943        let grand_total = total_passed + total_failed;
3944        let overall_rate = if grand_total == 0 {
3945            0.0
3946        } else {
3947            (total_passed as f64 / grand_total as f64) * 100.0
3948        };
3949
3950        println!("  {}", "-".repeat(76));
3951        println!(
3952            "  {:<40} {:>8} {:>8} {:>7.1}% {:>6.1}s",
3953            format!("TOTAL ({} targets)", num_targets),
3954            total_passed,
3955            total_failed,
3956            overall_rate,
3957            total_elapsed.as_secs_f64()
3958        );
3959        println!("{}", "=".repeat(80));
3960
3961        // Print per-target OWASP coverage
3962        for result in &target_results {
3963            println!("\n  OWASP API Security Top 10 Coverage for {}:", result.url);
3964            for entry in &result.owasp_coverage {
3965                let status = if !entry.tested {
3966                    "-"
3967                } else if entry.all_passed {
3968                    "pass"
3969                } else {
3970                    "FAIL"
3971                };
3972                let via = if entry.via_categories.is_empty() {
3973                    String::new()
3974                } else {
3975                    format!(" (via {})", entry.via_categories.join(", "))
3976                };
3977                println!("    {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
3978            }
3979        }
3980
3981        // Save combined summary
3982        let per_target_summaries: Vec<serde_json::Value> = target_results
3983            .iter()
3984            .enumerate()
3985            .map(|(idx, r)| {
3986                let total_checks = r.passed + r.failed;
3987                let rate = if total_checks == 0 {
3988                    0.0
3989                } else {
3990                    (r.passed as f64 / total_checks as f64) * 100.0
3991                };
3992                let owasp_json: Vec<serde_json::Value> = r
3993                    .owasp_coverage
3994                    .iter()
3995                    .map(|e| {
3996                        serde_json::json!({
3997                            "id": e.id,
3998                            "name": e.name,
3999                            "tested": e.tested,
4000                            "all_passed": e.all_passed,
4001                            "via_categories": e.via_categories,
4002                        })
4003                    })
4004                    .collect();
4005                serde_json::json!({
4006                    "target_url": r.url,
4007                    "target_index": idx,
4008                    "checks_passed": r.passed,
4009                    "checks_failed": r.failed,
4010                    "total_checks": total_checks,
4011                    "pass_rate": rate,
4012                    "elapsed_seconds": r.elapsed.as_secs_f64(),
4013                    "report": r.report_json,
4014                    "owasp_coverage": owasp_json,
4015                })
4016            })
4017            .collect();
4018
4019        let combined_summary = serde_json::json!({
4020            "total_targets": num_targets,
4021            "total_checks_passed": total_passed,
4022            "total_checks_failed": total_failed,
4023            "overall_pass_rate": overall_rate,
4024            "total_elapsed_seconds": total_elapsed.as_secs_f64(),
4025            "targets": per_target_summaries,
4026        });
4027
4028        let summary_path = self.output.join("multi-target-conformance-summary.json");
4029        let summary_str = serde_json::to_string_pretty(&combined_summary)
4030            .map_err(|e| BenchError::Other(format!("Failed to serialize summary: {}", e)))?;
4031        std::fs::write(&summary_path, &summary_str)
4032            .map_err(|e| BenchError::Other(format!("Failed to write summary: {}", e)))?;
4033        TerminalReporter::print_success(&format!(
4034            "Combined summary saved to: {}",
4035            summary_path.display()
4036        ));
4037
4038        Ok(())
4039    }
4040
4041    /// Execute OWASP API Security Top 10 testing mode
4042    async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
4043        TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
4044
4045        // Parse custom headers from CLI
4046        let custom_headers = self.parse_headers()?;
4047
4048        // Build OWASP configuration from CLI options
4049        let mut config = OwaspApiConfig::new()
4050            .with_auth_header(&self.owasp_auth_header)
4051            .with_verbose(self.verbose)
4052            .with_insecure(self.skip_tls_verify)
4053            .with_concurrency(self.vus as usize)
4054            .with_iterations(self.owasp_iterations as usize)
4055            .with_base_path(self.base_path.clone())
4056            .with_custom_headers(custom_headers);
4057
4058        // Set valid auth token if provided
4059        if let Some(ref token) = self.owasp_auth_token {
4060            config = config.with_valid_auth_token(token);
4061        }
4062
4063        // Parse categories if provided
4064        if let Some(ref cats_str) = self.owasp_categories {
4065            let categories: Vec<OwaspCategory> = cats_str
4066                .split(',')
4067                .filter_map(|s| {
4068                    let trimmed = s.trim();
4069                    match trimmed.parse::<OwaspCategory>() {
4070                        Ok(cat) => Some(cat),
4071                        Err(e) => {
4072                            TerminalReporter::print_warning(&e);
4073                            None
4074                        }
4075                    }
4076                })
4077                .collect();
4078
4079            if !categories.is_empty() {
4080                config = config.with_categories(categories);
4081            }
4082        }
4083
4084        // Load admin paths from file if provided
4085        if let Some(ref admin_paths_file) = self.owasp_admin_paths {
4086            config.admin_paths_file = Some(admin_paths_file.clone());
4087            if let Err(e) = config.load_admin_paths() {
4088                TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
4089            }
4090        }
4091
4092        // Set ID fields if provided
4093        if let Some(ref id_fields_str) = self.owasp_id_fields {
4094            let id_fields: Vec<String> = id_fields_str
4095                .split(',')
4096                .map(|s| s.trim().to_string())
4097                .filter(|s| !s.is_empty())
4098                .collect();
4099            if !id_fields.is_empty() {
4100                config = config.with_id_fields(id_fields);
4101            }
4102        }
4103
4104        // Set report path and format
4105        if let Some(ref report_path) = self.owasp_report {
4106            config = config.with_report_path(report_path);
4107        }
4108        if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
4109            config = config.with_report_format(format);
4110        }
4111
4112        // Print configuration summary
4113        let categories = config.categories_to_test();
4114        TerminalReporter::print_success(&format!(
4115            "Testing {} OWASP categories: {}",
4116            categories.len(),
4117            categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
4118        ));
4119
4120        if config.valid_auth_token.is_some() {
4121            TerminalReporter::print_progress("Using provided auth token for baseline requests");
4122        }
4123
4124        // Create the OWASP generator
4125        TerminalReporter::print_progress("Generating OWASP security test script...");
4126        let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
4127
4128        // Generate the script
4129        let script = generator.generate()?;
4130        TerminalReporter::print_success("OWASP security test script generated");
4131
4132        // Write script to file
4133        let script_path = if let Some(output) = &self.script_output {
4134            output.clone()
4135        } else {
4136            self.output.join("k6-owasp-security-test.js")
4137        };
4138
4139        if let Some(parent) = script_path.parent() {
4140            std::fs::create_dir_all(parent)?;
4141        }
4142        std::fs::write(&script_path, &script)?;
4143        TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
4144
4145        // If generate-only mode, exit here
4146        if self.generate_only {
4147            println!("\nOWASP security test script generated. Run it with:");
4148            println!("  k6 run {}", script_path.display());
4149            return Ok(());
4150        }
4151
4152        // Execute k6
4153        TerminalReporter::print_progress("Executing OWASP security tests...");
4154        let executor = K6Executor::new()?
4155            .with_local_ips(self.source_ips.join(","))
4156            .with_dns_policy(self.dns_policy.clone().unwrap_or_default());
4157        std::fs::create_dir_all(&self.output)?;
4158
4159        let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
4160
4161        let duration_secs = Self::parse_duration(&self.duration)?;
4162        TerminalReporter::print_summary_with_mode(&results, duration_secs, self.no_keep_alive);
4163
4164        println!("\nOWASP security test results saved to: {}", self.output.display());
4165
4166        Ok(())
4167    }
4168}
4169
4170#[cfg(test)]
4171mod tests {
4172    use super::*;
4173    use tempfile::tempdir;
4174
4175    #[test]
4176    fn test_parse_duration() {
4177        assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
4178        assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
4179        assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
4180        assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
4181    }
4182
4183    /// Round 22.4 — `start-end` IPv4 range syntax for non-power-of-2
4184    /// ranges. Srikanth (h): `--source-ip 10.0.0.5-10.0.0.27` for 23
4185    /// hosts without finding a clean prefix.
4186    #[test]
4187    fn parse_ip_list_ipv4_range_inclusive() {
4188        let v = parse_ip_list(&["10.0.0.5-10.0.0.27".into()], "source-ip");
4189        assert_eq!(v.len(), 23);
4190        assert_eq!(v.first().unwrap().to_string(), "10.0.0.5");
4191        assert_eq!(v.last().unwrap().to_string(), "10.0.0.27");
4192    }
4193
4194    /// Round 22.4 — range with start > end is rejected with a warning
4195    /// (returns nothing for that entry rather than wrapping around).
4196    #[test]
4197    fn parse_ip_list_range_rejects_backwards() {
4198        let v = parse_ip_list(&["10.0.0.10-10.0.0.5".into()], "source-ip");
4199        assert!(v.is_empty(), "backwards range should produce no IPs; got {v:?}");
4200    }
4201
4202    /// Round 22.4 — IPv6 ranges are intentionally rejected because
4203    /// `2001:db8::1-2001:db8::5` would ambiguously parse against the
4204    /// address literal's `:` separators. Users use CIDR for IPv6.
4205    #[test]
4206    fn parse_ip_list_rejects_ipv6_range_syntax() {
4207        let v = parse_ip_list(&["2001:db8::1-2001:db8::5".into()], "geo-source-ip");
4208        assert!(v.is_empty(), "IPv6 range should be rejected; got {v:?}");
4209    }
4210
4211    /// Round 22.4 — range cap is the same 256 host limit as CIDR.
4212    #[test]
4213    fn parse_ip_list_range_capped_at_256() {
4214        let v = parse_ip_list(&["10.0.0.0-10.0.5.0".into()], "source-ip");
4215        assert_eq!(v.len(), 256);
4216        assert_eq!(v.first().unwrap().to_string(), "10.0.0.0");
4217    }
4218
4219    /// Round 19 — single IPs and comma-separated lists already
4220    /// worked in 18.5; this regression-locks the parse paths.
4221    #[test]
4222    fn parse_ip_list_plain_and_comma() {
4223        let v = parse_ip_list(&["10.0.0.5".into(), "10.0.0.6,10.0.0.7".into()], "source-ip");
4224        assert_eq!(v.len(), 3);
4225        assert_eq!(v[0].to_string(), "10.0.0.5");
4226        assert_eq!(v[2].to_string(), "10.0.0.7");
4227    }
4228
4229    /// Round 19 — IPv4 CIDR expands to host count up to the cap.
4230    /// `/29` = 8 hosts (well under cap), all 8 enumerated.
4231    #[test]
4232    fn parse_ip_list_ipv4_cidr_29_expands_to_8() {
4233        let v = parse_ip_list(&["10.0.0.0/29".into()], "source-ip");
4234        assert_eq!(v.len(), 8);
4235        assert_eq!(v[0].to_string(), "10.0.0.0");
4236        assert_eq!(v[7].to_string(), "10.0.0.7");
4237    }
4238
4239    /// Round 19 — IPv4 CIDR larger than the cap is truncated, not
4240    /// rejected. Cap is 256; `/8` would be 16M without the guard.
4241    #[test]
4242    fn parse_ip_list_ipv4_cidr_8_capped_at_256() {
4243        let v = parse_ip_list(&["10.0.0.0/8".into()], "source-ip");
4244        assert_eq!(v.len(), 256);
4245        assert_eq!(v[0].to_string(), "10.0.0.0");
4246        assert_eq!(v[255].to_string(), "10.0.0.255");
4247    }
4248
4249    /// Round 19 — IPv6 CIDR also expands. `/126` = 4 hosts.
4250    #[test]
4251    fn parse_ip_list_ipv6_cidr_126_expands_to_4() {
4252        let v = parse_ip_list(&["2001:db8::/126".into()], "geo-source-ip");
4253        assert_eq!(v.len(), 4);
4254        assert!(v[0].is_ipv6());
4255        assert_eq!(v[0].to_string(), "2001:db8::");
4256        assert_eq!(v[3].to_string(), "2001:db8::3");
4257    }
4258
4259    /// Round 19 — mixed IPv4 + IPv6 + CIDR in one call works.
4260    #[test]
4261    fn parse_ip_list_mixed_v4_v6_cidr() {
4262        let v = parse_ip_list(&["10.0.0.0/30,2001:db8::1,203.0.113.42".into()], "geo-source-ip");
4263        assert_eq!(v.len(), 6); // 4 from /30 + 1 + 1
4264        assert!(v.iter().any(|ip| ip.to_string() == "2001:db8::1"));
4265        assert!(v.iter().any(|ip| ip.to_string() == "203.0.113.42"));
4266    }
4267
4268    /// Round 19 — malformed entries log and skip; the run continues
4269    /// with whatever resolved.
4270    #[test]
4271    fn parse_ip_list_skips_malformed() {
4272        let v = parse_ip_list(
4273            &[
4274                "10.0.0.5".into(),
4275                "not-an-ip".into(),
4276                "10.0.0.6".into(),
4277                "/24".into(),
4278                "1.2.3.4/200".into(),
4279            ],
4280            "source-ip",
4281        );
4282        assert_eq!(v.len(), 2);
4283        assert_eq!(v[0].to_string(), "10.0.0.5");
4284        assert_eq!(v[1].to_string(), "10.0.0.6");
4285    }
4286
4287    #[test]
4288    fn test_parse_duration_invalid() {
4289        assert!(BenchCommand::parse_duration("invalid").is_err());
4290        assert!(BenchCommand::parse_duration("30x").is_err());
4291    }
4292
4293    #[test]
4294    fn test_parse_headers() {
4295        let cmd = BenchCommand {
4296            spec: vec![PathBuf::from("test.yaml")],
4297            spec_dir: None,
4298            merge_conflicts: "error".to_string(),
4299            spec_mode: "merge".to_string(),
4300            dependency_config: None,
4301            target: "http://localhost".to_string(),
4302            base_path: None,
4303            duration: "1m".to_string(),
4304            vus: 10,
4305            scenario: "ramp-up".to_string(),
4306            operations: None,
4307            exclude_operations: None,
4308            auth: None,
4309            headers: vec![
4310                "X-API-Key:test123".to_string(),
4311                "X-Client-ID:client456".to_string(),
4312            ],
4313            output: PathBuf::from("output"),
4314            generate_only: false,
4315            script_output: None,
4316            threshold_percentile: "p(95)".to_string(),
4317            threshold_ms: 500,
4318            max_error_rate: 0.05,
4319            abort_on_error: true,
4320            abort_on_error_rate: 0.95,
4321            verbose: false,
4322            skip_tls_verify: false,
4323            chunked_request_bodies: false,
4324            target_rps: None,
4325            no_keep_alive: false,
4326            targets_file: None,
4327            max_concurrency: None,
4328            results_format: "both".to_string(),
4329            params_file: None,
4330            crud_flow: false,
4331            flow_config: None,
4332            extract_fields: None,
4333            parallel_create: None,
4334            data_file: None,
4335            data_distribution: "unique-per-vu".to_string(),
4336            data_mappings: None,
4337            per_uri_control: false,
4338            error_rate: None,
4339            error_types: None,
4340            security_test: false,
4341            security_payloads: None,
4342            security_categories: None,
4343            security_target_fields: None,
4344            wafbench_dir: None,
4345            wafbench_cycle_all: false,
4346            wafbench_verbatim: false,
4347            owasp_api_top10: false,
4348            owasp_categories: None,
4349            owasp_auth_header: "Authorization".to_string(),
4350            owasp_auth_token: None,
4351            owasp_admin_paths: None,
4352            owasp_id_fields: None,
4353            owasp_report: None,
4354            owasp_report_format: "json".to_string(),
4355            owasp_iterations: 1,
4356            conformance: false,
4357            conformance_api_key: None,
4358            conformance_basic_auth: None,
4359            conformance_report: PathBuf::from("conformance-report.json"),
4360            conformance_categories: None,
4361            conformance_report_format: "json".to_string(),
4362            conformance_headers: vec![],
4363            conformance_all_operations: false,
4364            conformance_custom: None,
4365            conformance_delay_ms: 0,
4366            use_k6: false,
4367            conformance_custom_filter: None,
4368            export_requests: false,
4369            validate_requests: false,
4370            conformance_self_test: false,
4371            conformance_self_test_capture: false,
4372            conformance_self_test_iterations: 1,
4373            conformance_self_test_duration: None,
4374            validate_response_schemas: false,
4375            source_ips: Vec::new(),
4376            geo_source_ips: Vec::new(),
4377            geo_source_headers: Vec::new(),
4378            report_missed_cap: None,
4379            discard_response_bodies: false,
4380            dns_policy: None,
4381        };
4382
4383        let headers = cmd.parse_headers().unwrap();
4384        assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
4385        assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
4386    }
4387
4388    #[test]
4389    fn test_parse_header_string_preserves_comma_in_value() {
4390        // #761: with one header per --headers flag, a comma in the value (e.g. a
4391        // Cookie expiry date) is preserved instead of being split into junk pairs.
4392        let inputs = vec![
4393            "Cookie:session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string(),
4394            "X-Trace:1".to_string(),
4395        ];
4396        let headers = parse_header_string(&inputs).unwrap();
4397        assert_eq!(
4398            headers.get("Cookie"),
4399            Some(&"session=abc; expires=Thu, 01 Jan 2099 00:00:00 GMT".to_string())
4400        );
4401        assert_eq!(headers.get("X-Trace"), Some(&"1".to_string()));
4402    }
4403
4404    /// #980. The advisory that fires under `--conformance` must name every
4405    /// load-shaping flag it discards AND say the load run is replaced.
4406    ///
4407    /// The previous wording named only `--vus` and `-d`. It omitted `--rps`,
4408    /// and it never said the load run does not happen at all — so a command
4409    /// carrying a full set of load flags looked like it would do both. That
4410    /// gap is what produced the wrong advice on #79 round 64.
4411    #[test]
4412    fn conformance_advisory_names_every_discarded_flag() {
4413        let msg = CONFORMANCE_REPLACES_LOAD_ADVISORY;
4414        for flag in ["--vus", "--rps", "-d"] {
4415            assert!(
4416                msg.contains(flag),
4417                "conformance advisory must name `{flag}` as ignored; it is discarded on that \
4418                 path and silently dropping it is how users end up tuning a knob that does \
4419                 nothing (#980). Message was: {msg}"
4420            );
4421        }
4422        assert!(
4423            msg.contains("REPLACES"),
4424            "conformance advisory must say the load run is REPLACED, not merely that some \
4425             flags are ignored — `--conformance` returns before the load path runs, so no \
4426             load traffic is generated at all (#980). Message was: {msg}"
4427        );
4428    }
4429
4430    /// Round 64 (#79). `execute_multi_target` hand-copies `BenchCommand` field
4431    /// by field into the command it hands `ParallelExecutor`. It nulled the
4432    /// conformance auth shortcuts, so `parse_headers()` — which has folded them
4433    /// into the shared header map since round 47 — had nothing left to fold.
4434    /// Single-target sent `Authorization`; multi-target silently sent nothing,
4435    /// which is what Srikanth saw on 0.3.210: credentials absent from both his
4436    /// PCAP and his proxy.
4437    ///
4438    /// A behavioural test cannot reach that clone (`execute_multi_target` is
4439    /// async, parses a targets file and shells out to k6), so this guards the
4440    /// source: every field `parse_headers()` reads must survive the clone.
4441    /// Field-by-field struct literals silently drop things; this makes the drop
4442    /// a test failure instead of an empty Authorization header.
4443    #[test]
4444    fn multi_target_clone_preserves_fields_parse_headers_reads() {
4445        let src = include_str!("command.rs");
4446
4447        let fn_start = src
4448            .find("async fn execute_multi_target(")
4449            .expect("execute_multi_target should exist");
4450        let block_start = src[fn_start..]
4451            .find("ParallelExecutor::new(")
4452            .map(|i| i + fn_start)
4453            .expect("multi-target path should build a ParallelExecutor");
4454        // The BenchCommand literal ends at the executor call's closing paren.
4455        let block_end = src[block_start..]
4456            .find("\n        );")
4457            .map(|i| i + block_start)
4458            .expect("ParallelExecutor::new(..) should be closed");
4459        let block = &src[block_start..block_end];
4460
4461        // Read straight out of parse_headers' doc/body contract: these are the
4462        // auth-bearing inputs it folds. Keep in sync if that fold grows.
4463        for field in ["conformance_basic_auth", "conformance_headers"] {
4464            for zeroed in [format!("{field}: None"), format!("{field}: vec![]")] {
4465                assert!(
4466                    !block.contains(&zeroed),
4467                    "execute_multi_target zeroes `{zeroed}`. parse_headers() folds `{field}` \
4468                     into the header map, so zeroing it here strips auth from every \
4469                     multi-target run while single-target keeps working (#79 round 64)."
4470                );
4471            }
4472            let passthrough = format!("{field}: self.{field}.clone()");
4473            assert!(
4474                block.contains(&passthrough),
4475                "execute_multi_target must carry `{field}` through as `{passthrough}` so \
4476                 parse_headers() can fold it (#79 round 64)."
4477            );
4478        }
4479    }
4480
4481    #[test]
4482    fn test_get_spec_display_name() {
4483        let cmd = BenchCommand {
4484            spec: vec![PathBuf::from("test.yaml")],
4485            spec_dir: None,
4486            merge_conflicts: "error".to_string(),
4487            spec_mode: "merge".to_string(),
4488            dependency_config: None,
4489            target: "http://localhost".to_string(),
4490            base_path: None,
4491            duration: "1m".to_string(),
4492            vus: 10,
4493            scenario: "ramp-up".to_string(),
4494            operations: None,
4495            exclude_operations: None,
4496            auth: None,
4497            headers: Vec::new(),
4498            output: PathBuf::from("output"),
4499            generate_only: false,
4500            script_output: None,
4501            threshold_percentile: "p(95)".to_string(),
4502            threshold_ms: 500,
4503            max_error_rate: 0.05,
4504            abort_on_error: true,
4505            abort_on_error_rate: 0.95,
4506            verbose: false,
4507            skip_tls_verify: false,
4508            chunked_request_bodies: false,
4509            target_rps: None,
4510            no_keep_alive: false,
4511            targets_file: None,
4512            max_concurrency: None,
4513            results_format: "both".to_string(),
4514            params_file: None,
4515            crud_flow: false,
4516            flow_config: None,
4517            extract_fields: None,
4518            parallel_create: None,
4519            data_file: None,
4520            data_distribution: "unique-per-vu".to_string(),
4521            data_mappings: None,
4522            per_uri_control: false,
4523            error_rate: None,
4524            error_types: None,
4525            security_test: false,
4526            security_payloads: None,
4527            security_categories: None,
4528            security_target_fields: None,
4529            wafbench_dir: None,
4530            wafbench_cycle_all: false,
4531            wafbench_verbatim: false,
4532            owasp_api_top10: false,
4533            owasp_categories: None,
4534            owasp_auth_header: "Authorization".to_string(),
4535            owasp_auth_token: None,
4536            owasp_admin_paths: None,
4537            owasp_id_fields: None,
4538            owasp_report: None,
4539            owasp_report_format: "json".to_string(),
4540            owasp_iterations: 1,
4541            conformance: false,
4542            conformance_api_key: None,
4543            conformance_basic_auth: None,
4544            conformance_report: PathBuf::from("conformance-report.json"),
4545            conformance_categories: None,
4546            conformance_report_format: "json".to_string(),
4547            conformance_headers: vec![],
4548            conformance_all_operations: false,
4549            conformance_custom: None,
4550            conformance_delay_ms: 0,
4551            use_k6: false,
4552            conformance_custom_filter: None,
4553            export_requests: false,
4554            validate_requests: false,
4555            conformance_self_test: false,
4556            conformance_self_test_capture: false,
4557            conformance_self_test_iterations: 1,
4558            conformance_self_test_duration: None,
4559            validate_response_schemas: false,
4560            source_ips: Vec::new(),
4561            geo_source_ips: Vec::new(),
4562            geo_source_headers: Vec::new(),
4563            report_missed_cap: None,
4564            discard_response_bodies: false,
4565            dns_policy: None,
4566        };
4567
4568        assert_eq!(cmd.get_spec_display_name(), "test.yaml");
4569
4570        // Test multiple specs
4571        let cmd_multi = BenchCommand {
4572            spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
4573            spec_dir: None,
4574            merge_conflicts: "error".to_string(),
4575            spec_mode: "merge".to_string(),
4576            dependency_config: None,
4577            target: "http://localhost".to_string(),
4578            base_path: None,
4579            duration: "1m".to_string(),
4580            vus: 10,
4581            scenario: "ramp-up".to_string(),
4582            operations: None,
4583            exclude_operations: None,
4584            auth: None,
4585            headers: Vec::new(),
4586            output: PathBuf::from("output"),
4587            generate_only: false,
4588            script_output: None,
4589            threshold_percentile: "p(95)".to_string(),
4590            threshold_ms: 500,
4591            max_error_rate: 0.05,
4592            abort_on_error: true,
4593            abort_on_error_rate: 0.95,
4594            verbose: false,
4595            skip_tls_verify: false,
4596            chunked_request_bodies: false,
4597            target_rps: None,
4598            no_keep_alive: false,
4599            targets_file: None,
4600            max_concurrency: None,
4601            results_format: "both".to_string(),
4602            params_file: None,
4603            crud_flow: false,
4604            flow_config: None,
4605            extract_fields: None,
4606            parallel_create: None,
4607            data_file: None,
4608            data_distribution: "unique-per-vu".to_string(),
4609            data_mappings: None,
4610            per_uri_control: false,
4611            error_rate: None,
4612            error_types: None,
4613            security_test: false,
4614            security_payloads: None,
4615            security_categories: None,
4616            security_target_fields: None,
4617            wafbench_dir: None,
4618            wafbench_cycle_all: false,
4619            wafbench_verbatim: false,
4620            owasp_api_top10: false,
4621            owasp_categories: None,
4622            owasp_auth_header: "Authorization".to_string(),
4623            owasp_auth_token: None,
4624            owasp_admin_paths: None,
4625            owasp_id_fields: None,
4626            owasp_report: None,
4627            owasp_report_format: "json".to_string(),
4628            owasp_iterations: 1,
4629            conformance: false,
4630            conformance_api_key: None,
4631            conformance_basic_auth: None,
4632            conformance_report: PathBuf::from("conformance-report.json"),
4633            conformance_categories: None,
4634            conformance_report_format: "json".to_string(),
4635            conformance_headers: vec![],
4636            conformance_all_operations: false,
4637            conformance_custom: None,
4638            conformance_delay_ms: 0,
4639            use_k6: false,
4640            conformance_custom_filter: None,
4641            export_requests: false,
4642            validate_requests: false,
4643            conformance_self_test: false,
4644            conformance_self_test_capture: false,
4645            conformance_self_test_iterations: 1,
4646            conformance_self_test_duration: None,
4647            validate_response_schemas: false,
4648            source_ips: Vec::new(),
4649            geo_source_ips: Vec::new(),
4650            geo_source_headers: Vec::new(),
4651            report_missed_cap: None,
4652            discard_response_bodies: false,
4653            dns_policy: None,
4654        };
4655
4656        assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
4657    }
4658
4659    #[test]
4660    fn test_parse_extracted_values_from_output_dir() {
4661        let dir = tempdir().unwrap();
4662        let path = dir.path().join("extracted_values.json");
4663        std::fs::write(
4664            &path,
4665            r#"{
4666  "pool_id": "abc123",
4667  "count": 0,
4668  "enabled": false,
4669  "metadata": { "owner": "team-a" }
4670}"#,
4671        )
4672        .unwrap();
4673
4674        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4675        assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
4676        assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
4677        assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
4678        assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
4679    }
4680
4681    #[test]
4682    fn test_parse_extracted_values_missing_file() {
4683        let dir = tempdir().unwrap();
4684        let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
4685        assert!(extracted.values.is_empty());
4686    }
4687
4688    /// Full `BenchCommand` literal for tests. Extracted from the existing
4689    /// header-parsing test so new tests do not have to restate 80+ fields.
4690    fn sample_bench_command() -> BenchCommand {
4691        BenchCommand {
4692            spec: vec![PathBuf::from("test.yaml")],
4693            spec_dir: None,
4694            merge_conflicts: "error".to_string(),
4695            spec_mode: "merge".to_string(),
4696            dependency_config: None,
4697            target: "http://localhost".to_string(),
4698            base_path: None,
4699            duration: "1m".to_string(),
4700            vus: 10,
4701            scenario: "ramp-up".to_string(),
4702            operations: None,
4703            exclude_operations: None,
4704            auth: None,
4705            headers: vec![
4706                "X-API-Key:test123".to_string(),
4707                "X-Client-ID:client456".to_string(),
4708            ],
4709            output: PathBuf::from("output"),
4710            generate_only: false,
4711            script_output: None,
4712            threshold_percentile: "p(95)".to_string(),
4713            threshold_ms: 500,
4714            max_error_rate: 0.05,
4715            abort_on_error: true,
4716            abort_on_error_rate: 0.95,
4717            verbose: false,
4718            skip_tls_verify: false,
4719            chunked_request_bodies: false,
4720            target_rps: None,
4721            no_keep_alive: false,
4722            targets_file: None,
4723            max_concurrency: None,
4724            results_format: "both".to_string(),
4725            params_file: None,
4726            crud_flow: false,
4727            flow_config: None,
4728            extract_fields: None,
4729            parallel_create: None,
4730            data_file: None,
4731            data_distribution: "unique-per-vu".to_string(),
4732            data_mappings: None,
4733            per_uri_control: false,
4734            error_rate: None,
4735            error_types: None,
4736            security_test: false,
4737            security_payloads: None,
4738            security_categories: None,
4739            security_target_fields: None,
4740            wafbench_dir: None,
4741            wafbench_cycle_all: false,
4742            wafbench_verbatim: false,
4743            owasp_api_top10: false,
4744            owasp_categories: None,
4745            owasp_auth_header: "Authorization".to_string(),
4746            owasp_auth_token: None,
4747            owasp_admin_paths: None,
4748            owasp_id_fields: None,
4749            owasp_report: None,
4750            owasp_report_format: "json".to_string(),
4751            owasp_iterations: 1,
4752            conformance: false,
4753            conformance_api_key: None,
4754            conformance_basic_auth: None,
4755            conformance_report: PathBuf::from("conformance-report.json"),
4756            conformance_categories: None,
4757            conformance_report_format: "json".to_string(),
4758            conformance_headers: vec![],
4759            conformance_all_operations: false,
4760            conformance_custom: None,
4761            conformance_delay_ms: 0,
4762            use_k6: false,
4763            conformance_custom_filter: None,
4764            export_requests: false,
4765            validate_requests: false,
4766            conformance_self_test: false,
4767            conformance_self_test_capture: false,
4768            conformance_self_test_iterations: 1,
4769            conformance_self_test_duration: None,
4770            validate_response_schemas: false,
4771            source_ips: Vec::new(),
4772            geo_source_ips: Vec::new(),
4773            geo_source_headers: Vec::new(),
4774            report_missed_cap: None,
4775            discard_response_bodies: false,
4776            dns_policy: None,
4777        }
4778    }
4779
4780    /// #997 regression. `--wafbench-dir` in verbatim mode supplies the REQUESTS,
4781    /// not a payload pool. If the payload-injection layer stays on, the k6
4782    /// script appends `&test=<payload>` to every request, which (a) mutates the
4783    /// cases the user asked to be sent exactly as written and (b) attaches an
4784    /// attack payload to `expected: 200` cases, so a correct WAF blocks them and
4785    /// the run reports a failure the user never wrote. Verified on the wire
4786    /// against a logging listener before this test was written.
4787    #[test]
4788    fn verbatim_disables_security_payload_injection() {
4789        let mut cmd = sample_bench_command();
4790        cmd.wafbench_dir = Some("traffic.yaml".to_string());
4791
4792        assert!(
4793            cmd.security_testing_enabled(),
4794            "--wafbench-dir alone must still enable payload injection"
4795        );
4796
4797        cmd.wafbench_verbatim = true;
4798        assert!(
4799            !cmd.security_testing_enabled(),
4800            "verbatim mode must not inject payloads into requests sent as written"
4801        );
4802
4803        // Explicitly asking for both is contradictory; verbatim wins and the
4804        // command warns rather than silently mutating the traffic.
4805        cmd.security_test = true;
4806        assert!(
4807            !cmd.security_testing_enabled(),
4808            "--security-test must not re-enable injection under --wafbench-verbatim"
4809        );
4810    }
4811
4812    /// The template gates `{{#if security_testing_enabled}}` on this flag, and
4813    /// it was previously recomputed inline at four render sites. Any site that
4814    /// disagreed produced dead code or a call to an undefined function -- the
4815    /// #79 drift shape. Keep exactly one definition.
4816    #[test]
4817    fn security_testing_enabled_has_a_single_definition() {
4818        let src = include_str!("command.rs");
4819        // Assembled at runtime: a literal needle would match itself in this file.
4820        let a = format!("self.{} || self.{}.is_some()", "security_test", "wafbench_dir");
4821        let b = format!("self.{}.is_some() || self.{}", "wafbench_dir", "security_test");
4822        let inline = src.matches(a.as_str()).count() + src.matches(b.as_str()).count();
4823        assert_eq!(
4824            inline, 1,
4825            "expected the security_testing_enabled() method to be the only place this is \
4826             computed, found {inline} inline copies -- collapse them or the render paths drift"
4827        );
4828    }
4829}