Skip to main content

mockforge_bench/
cloud_api.rs

1//! Cloud-friendly entry points for invoking bench, conformance, OWASP,
2//! security-payload, WAFBench, and CRUD-flow runs programmatically.
3//!
4//! The CLI in `command.rs` is the primary user-facing surface, but it assumes
5//! the caller can supply paths on disk and is OK with stdout reporting. Cloud
6//! callers (the registry server) need to:
7//!
8//! 1. Pass the OpenAPI spec as raw bytes (no filesystem coordination).
9//! 2. Receive every artifact that was written to the run's output directory
10//!    as in-memory bytes, so they can be persisted to Postgres / Tigris.
11//! 3. Read structured `K6Results` without re-parsing `summary.json`.
12//!
13//! This module provides exactly that. Each `run_*` function:
14//!
15//! * Creates a private tempdir,
16//! * Writes the supplied spec bytes into it,
17//! * Builds a [`BenchCommand`] with cloud-appropriate defaults,
18//! * Executes the run,
19//! * Slurps every file produced under the output dir into a
20//!   [`CloudRunArtifacts`] map.
21//!
22//! The CLI is unchanged — it still uses [`BenchCommand`] directly. Progress
23//! reporting (`TerminalReporter`) still goes to stdout; suppressing or
24//! redirecting it is intentionally out of scope for this module and will be
25//! handled by a follow-up that introduces a `ProgressSink`.
26
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use tempfile::TempDir;
31
32use crate::command::BenchCommand;
33use crate::error::{BenchError, Result};
34use crate::executor::{K6Executor, K6Results};
35use crate::ssrf::{validate_target_url, Policy as SsrfPolicy};
36
37/// Resolve the SSRF policy to apply to cloud-driven runs.
38///
39/// Defaults to [`SsrfPolicy::strict`]. The env var
40/// `MOCKFORGE_SSRF_ALLOW_LOOPBACK=1` opts into [`SsrfPolicy::for_test`] —
41/// **only** intended for integration tests that target a local mock
42/// server on `127.0.0.1`. Production deployments must NOT set this.
43fn resolve_ssrf_policy() -> SsrfPolicy {
44    match std::env::var("MOCKFORGE_SSRF_ALLOW_LOOPBACK").as_deref() {
45        Ok("1") | Ok("true") => SsrfPolicy::for_test(),
46        _ => SsrfPolicy::strict(),
47    }
48}
49
50/// Validate the supplied target URL against the SSRF policy and convert
51/// any rejection into a [`BenchError`] so existing call-sites don't need
52/// a new error variant.
53async fn enforce_ssrf(target_url: &str) -> Result<()> {
54    let policy = resolve_ssrf_policy();
55    validate_target_url(target_url, policy)
56        .await
57        .map_err(|e| BenchError::Other(format!("SSRF guard rejected target: {}", e)))
58}
59
60/// Format hint for OpenAPI specs supplied as raw bytes.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub enum SpecFormat {
63    /// Pretty-printed JSON or a single-line JSON document.
64    Json,
65    /// YAML 1.1/1.2 document.
66    Yaml,
67    /// Sniff from the first non-whitespace byte: `{`/`[` → JSON, otherwise YAML.
68    #[default]
69    Auto,
70}
71
72impl SpecFormat {
73    fn extension(self, bytes: &[u8]) -> &'static str {
74        match self {
75            SpecFormat::Json => "json",
76            SpecFormat::Yaml => "yaml",
77            SpecFormat::Auto => match bytes.iter().find(|b| !b.is_ascii_whitespace()) {
78                Some(b'{') | Some(b'[') => "json",
79                _ => "yaml",
80            },
81        }
82    }
83}
84
85/// Inputs for [`run_bench`] — a k6 load test against an external URL.
86///
87/// All fields mirror the CLI flags one-for-one, minus filesystem-only options
88/// (`--output`, `--script-output`, `--targets-file`, etc.) that have no
89/// meaning in a hosted context.
90#[derive(Debug, Clone)]
91pub struct CloudBenchInputs {
92    pub spec_bytes: Vec<u8>,
93    pub spec_format: SpecFormat,
94    pub target_url: String,
95    pub base_path: Option<String>,
96    pub duration: String,
97    pub vus: u32,
98    pub scenario: String,
99    pub operations: Option<String>,
100    pub exclude_operations: Option<String>,
101    pub auth: Option<String>,
102    /// Additional headers, one `Key:Value` per entry (matches the repeated CLI
103    /// `--headers` flag — see [`crate::command::parse_header_string`]). One header
104    /// per entry so values may contain commas (#761).
105    pub headers: Vec<String>,
106    pub threshold_percentile: String,
107    pub threshold_ms: u64,
108    pub max_error_rate: f64,
109    pub skip_tls_verify: bool,
110    pub chunked_request_bodies: bool,
111}
112
113impl Default for CloudBenchInputs {
114    fn default() -> Self {
115        Self {
116            spec_bytes: Vec::new(),
117            spec_format: SpecFormat::Auto,
118            target_url: String::new(),
119            base_path: None,
120            duration: "30s".to_string(),
121            vus: 10,
122            scenario: "constant".to_string(),
123            operations: None,
124            exclude_operations: None,
125            auth: None,
126            headers: Vec::new(),
127            threshold_percentile: "p(95)".to_string(),
128            threshold_ms: 1000,
129            max_error_rate: 0.01,
130            skip_tls_verify: false,
131            chunked_request_bodies: false,
132        }
133    }
134}
135
136/// Inputs for [`run_conformance`] — OpenAPI 3.0.0 conformance testing against
137/// an external URL.
138///
139/// Setting `spec_bytes` enables spec-driven mode (preferred). Leaving it `None`
140/// falls through to the reference-check mode that the underlying executor
141/// supports.
142#[derive(Debug, Clone)]
143pub struct CloudConformanceInputs {
144    pub spec_bytes: Option<Vec<u8>>,
145    pub spec_format: SpecFormat,
146    pub target_url: String,
147    pub base_path: Option<String>,
148    pub api_key: Option<String>,
149    /// `user:pass` for HTTP basic auth.
150    pub basic_auth: Option<String>,
151    /// Comma-separated category list (e.g. `"parameters,security"`).
152    pub categories: Option<String>,
153    /// `Header-Name: value` strings, one per entry.
154    pub headers: Vec<String>,
155    pub all_operations: bool,
156    pub request_delay_ms: u64,
157    /// When true, route conformance through k6 instead of the native Rust
158    /// executor. Native is faster and the default.
159    pub use_k6: bool,
160    pub skip_tls_verify: bool,
161    /// `"json"` (default) or `"sarif"`.
162    pub report_format: String,
163    pub export_requests: bool,
164    pub validate_requests: bool,
165}
166
167impl Default for CloudConformanceInputs {
168    fn default() -> Self {
169        Self {
170            spec_bytes: None,
171            spec_format: SpecFormat::Auto,
172            target_url: String::new(),
173            base_path: None,
174            api_key: None,
175            basic_auth: None,
176            categories: None,
177            headers: Vec::new(),
178            all_operations: false,
179            request_delay_ms: 0,
180            use_k6: false,
181            skip_tls_verify: false,
182            report_format: "json".to_string(),
183            export_requests: false,
184            validate_requests: false,
185        }
186    }
187}
188
189/// Every artifact produced by a cloud run.
190///
191/// Each file under the run's output directory is read into `files` keyed by
192/// its filename. `k6_results` is populated from `summary.json` when the run
193/// went through k6.
194#[derive(Debug, Default, Clone)]
195pub struct CloudRunArtifacts {
196    pub k6_results: Option<K6Results>,
197    pub files: HashMap<String, Vec<u8>>,
198}
199
200impl CloudRunArtifacts {
201    pub fn get(&self, name: &str) -> Option<&[u8]> {
202        self.files.get(name).map(Vec::as_slice)
203    }
204
205    pub fn get_string(&self, name: &str) -> Option<String> {
206        self.get(name).map(|b| String::from_utf8_lossy(b).into_owned())
207    }
208
209    pub fn get_json(&self, name: &str) -> Option<serde_json::Value> {
210        self.get(name).and_then(|b| serde_json::from_slice(b).ok())
211    }
212}
213
214/// Run a k6 load test against [`CloudBenchInputs::target_url`] and return all
215/// produced artifacts in memory.
216///
217/// Requires the `k6` binary on `$PATH`. Returns [`BenchError::K6NotFound`] if
218/// not present.
219pub async fn run_bench(inputs: CloudBenchInputs) -> Result<CloudRunArtifacts> {
220    if inputs.target_url.trim().is_empty() {
221        return Err(BenchError::Other("target_url is required".to_string()));
222    }
223    if inputs.spec_bytes.is_empty() {
224        return Err(BenchError::Other("spec_bytes is required for bench runs".to_string()));
225    }
226    if !K6Executor::is_k6_installed() {
227        return Err(BenchError::K6NotFound);
228    }
229    enforce_ssrf(&inputs.target_url).await?;
230
231    let workdir = TempDir::new()
232        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
233    let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
234    let output_dir = workdir.path().join("output");
235    std::fs::create_dir_all(&output_dir)
236        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
237
238    let cmd = BenchCommand {
239        spec: vec![spec_path],
240        target: inputs.target_url,
241        base_path: inputs.base_path,
242        duration: inputs.duration,
243        vus: inputs.vus,
244        scenario: inputs.scenario,
245        operations: inputs.operations,
246        exclude_operations: inputs.exclude_operations,
247        auth: inputs.auth,
248        headers: inputs.headers,
249        threshold_percentile: inputs.threshold_percentile,
250        threshold_ms: inputs.threshold_ms,
251        max_error_rate: inputs.max_error_rate,
252        skip_tls_verify: inputs.skip_tls_verify,
253        chunked_request_bodies: inputs.chunked_request_bodies,
254        ..default_bench_command(&output_dir)
255    };
256
257    cmd.execute().await?;
258    read_artifacts(&output_dir)
259}
260
261/// Run an OpenAPI 3.0.0 conformance test against
262/// [`CloudConformanceInputs::target_url`].
263///
264/// When `use_k6` is false (default) the native Rust executor runs in-process —
265/// no k6 binary required. When `use_k6` is true, k6 must be on `$PATH`.
266pub async fn run_conformance(inputs: CloudConformanceInputs) -> Result<CloudRunArtifacts> {
267    if inputs.target_url.trim().is_empty() {
268        return Err(BenchError::Other("target_url is required".to_string()));
269    }
270    if inputs.use_k6 && !K6Executor::is_k6_installed() {
271        return Err(BenchError::K6NotFound);
272    }
273    enforce_ssrf(&inputs.target_url).await?;
274
275    let workdir = TempDir::new()
276        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
277    let output_dir = workdir.path().join("output");
278    std::fs::create_dir_all(&output_dir)
279        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
280
281    let spec_paths = if let Some(bytes) = &inputs.spec_bytes {
282        vec![write_spec(workdir.path(), bytes, inputs.spec_format)?]
283    } else {
284        Vec::new()
285    };
286
287    let report_path = output_dir.join("conformance-report.json");
288    let cmd = BenchCommand {
289        spec: spec_paths,
290        target: inputs.target_url,
291        base_path: inputs.base_path,
292        skip_tls_verify: inputs.skip_tls_verify,
293        conformance: true,
294        conformance_api_key: inputs.api_key,
295        conformance_basic_auth: inputs.basic_auth,
296        conformance_report: report_path,
297        conformance_categories: inputs.categories,
298        conformance_report_format: inputs.report_format,
299        conformance_headers: inputs.headers,
300        conformance_all_operations: inputs.all_operations,
301        conformance_delay_ms: inputs.request_delay_ms,
302        use_k6: inputs.use_k6,
303        export_requests: inputs.export_requests,
304        validate_requests: inputs.validate_requests,
305        ..default_bench_command(&output_dir)
306    };
307
308    cmd.execute().await?;
309    read_artifacts(&output_dir)
310}
311
312/// Inputs for [`run_owasp`] — OWASP API Security Top 10 testing.
313///
314/// Built-in `categories` cover OWASP API1–API10 and run k6-driven; supplying
315/// `admin_paths` is recommended for the BOLA / privilege-escalation checks
316/// (they default to a small built-in list otherwise).
317#[derive(Debug, Clone)]
318pub struct CloudOwaspInputs {
319    pub spec_bytes: Vec<u8>,
320    pub spec_format: SpecFormat,
321    pub target_url: String,
322    pub base_path: Option<String>,
323    /// Comma-separated list of OWASP category short names (e.g.
324    /// `"api1,api3,api7"`). Empty = all categories.
325    pub categories: Option<String>,
326    /// Header name to use for auth checks. Defaults to `"Authorization"`.
327    pub auth_header: String,
328    /// Valid token used as the baseline for auth-bypass checks. Without it,
329    /// auth-related findings are limited.
330    pub auth_token: Option<String>,
331    /// Inline list of admin / privileged paths (one per line in the OWASP
332    /// admin-paths file format — comments with `#` are allowed). When empty,
333    /// the OWASP generator's built-in default list is used.
334    pub admin_paths: Vec<String>,
335    /// Comma-separated field names known to be resource IDs (e.g.
336    /// `"id,user_id,order_id"`).
337    pub id_fields: Option<String>,
338    /// `"json"` (default) or `"sarif"`.
339    pub report_format: String,
340    /// Iterations per VU. Defaults to 1.
341    pub iterations: u32,
342    pub vus: u32,
343    pub skip_tls_verify: bool,
344    /// `Key:Value,Key2:Value2` header string.
345    pub headers: Vec<String>,
346}
347
348impl Default for CloudOwaspInputs {
349    fn default() -> Self {
350        Self {
351            spec_bytes: Vec::new(),
352            spec_format: SpecFormat::Auto,
353            target_url: String::new(),
354            base_path: None,
355            categories: None,
356            auth_header: "Authorization".to_string(),
357            auth_token: None,
358            admin_paths: Vec::new(),
359            id_fields: None,
360            report_format: "json".to_string(),
361            iterations: 1,
362            vus: 10,
363            skip_tls_verify: false,
364            headers: Vec::new(),
365        }
366    }
367}
368
369/// Inputs for [`run_security`] — payload-injection security testing layered on
370/// a standard k6 bench run.
371///
372/// Built-in payload categories (SQL injection, XSS, command injection, path
373/// traversal, etc.) are baked into the binary. Supplying a custom payloads
374/// file is intentionally not supported in cloud mode — submit overrides via
375/// `categories` instead.
376#[derive(Debug, Clone)]
377pub struct CloudSecurityInputs {
378    pub spec_bytes: Vec<u8>,
379    pub spec_format: SpecFormat,
380    pub target_url: String,
381    pub base_path: Option<String>,
382    pub duration: String,
383    pub vus: u32,
384    pub scenario: String,
385    /// Comma-separated category names (e.g. `"sql,xss,cmd"`). Empty = all.
386    pub categories: Option<String>,
387    /// Comma-separated field names to inject into (e.g. `"username,query"`).
388    pub target_fields: Option<String>,
389    pub auth: Option<String>,
390    pub headers: Vec<String>,
391    pub skip_tls_verify: bool,
392}
393
394impl Default for CloudSecurityInputs {
395    fn default() -> Self {
396        Self {
397            spec_bytes: Vec::new(),
398            spec_format: SpecFormat::Auto,
399            target_url: String::new(),
400            base_path: None,
401            duration: "30s".to_string(),
402            vus: 10,
403            scenario: "constant".to_string(),
404            categories: None,
405            target_fields: None,
406            auth: None,
407            headers: Vec::new(),
408            skip_tls_verify: false,
409        }
410    }
411}
412
413/// Inputs for [`run_wafbench`] — Microsoft WAFBench-style coverage tests using
414/// the OWASP Core Rule Set attack patterns.
415///
416/// `rules_dir` must be a directory or glob pattern reachable on the host
417/// running the bench. In production this is the bundled CRS install path
418/// (e.g. `/usr/share/mockforge/wafbench/`); leaving it empty is an error.
419#[derive(Debug, Clone)]
420pub struct CloudWafBenchInputs {
421    pub spec_bytes: Vec<u8>,
422    pub spec_format: SpecFormat,
423    pub target_url: String,
424    pub base_path: Option<String>,
425    pub duration: String,
426    pub vus: u32,
427    pub scenario: String,
428    /// Filesystem path or glob pattern to WAFBench rule YAMLs.
429    pub rules_dir: String,
430    /// When true, exhaustively cycle through every payload instead of random
431    /// sampling. Use for coverage runs; expect long durations.
432    pub cycle_all: bool,
433    pub auth: Option<String>,
434    pub headers: Vec<String>,
435    pub skip_tls_verify: bool,
436}
437
438impl Default for CloudWafBenchInputs {
439    fn default() -> Self {
440        Self {
441            spec_bytes: Vec::new(),
442            spec_format: SpecFormat::Auto,
443            target_url: String::new(),
444            base_path: None,
445            duration: "30s".to_string(),
446            vus: 10,
447            scenario: "constant".to_string(),
448            rules_dir: String::new(),
449            cycle_all: false,
450            auth: None,
451            headers: Vec::new(),
452            skip_tls_verify: false,
453        }
454    }
455}
456
457/// Inputs for [`run_crud_flow`] — CRUD chain testing (Create → Read → Update →
458/// Delete sequences with cross-step ID extraction).
459///
460/// When `flow_config_yaml` is `None`, flows are auto-detected from the spec.
461/// When provided, the YAML follows the schema understood by `CrudFlowConfig`.
462#[derive(Debug, Clone)]
463pub struct CloudCrudFlowInputs {
464    pub spec_bytes: Vec<u8>,
465    pub spec_format: SpecFormat,
466    pub target_url: String,
467    pub base_path: Option<String>,
468    pub duration: String,
469    pub vus: u32,
470    pub scenario: String,
471    /// Inline YAML defining custom flows. When `None`, flows are auto-detected
472    /// from the OpenAPI spec.
473    pub flow_config_yaml: Option<String>,
474    /// Comma-separated response fields to extract for cross-step references
475    /// (e.g. `"id,user_id"`).
476    pub extract_fields: Option<String>,
477    pub auth: Option<String>,
478    pub headers: Vec<String>,
479    pub skip_tls_verify: bool,
480}
481
482impl Default for CloudCrudFlowInputs {
483    fn default() -> Self {
484        Self {
485            spec_bytes: Vec::new(),
486            spec_format: SpecFormat::Auto,
487            target_url: String::new(),
488            base_path: None,
489            duration: "30s".to_string(),
490            vus: 10,
491            scenario: "constant".to_string(),
492            flow_config_yaml: None,
493            extract_fields: None,
494            auth: None,
495            headers: Vec::new(),
496            skip_tls_verify: false,
497        }
498    }
499}
500
501/// Run an OWASP API Security Top 10 test.
502///
503/// Requires k6 on `$PATH`.
504pub async fn run_owasp(inputs: CloudOwaspInputs) -> Result<CloudRunArtifacts> {
505    if inputs.target_url.trim().is_empty() {
506        return Err(BenchError::Other("target_url is required".to_string()));
507    }
508    if inputs.spec_bytes.is_empty() {
509        return Err(BenchError::Other("spec_bytes is required for OWASP runs".to_string()));
510    }
511    if !K6Executor::is_k6_installed() {
512        return Err(BenchError::K6NotFound);
513    }
514    enforce_ssrf(&inputs.target_url).await?;
515
516    let workdir = TempDir::new()
517        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
518    let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
519    let output_dir = workdir.path().join("output");
520    std::fs::create_dir_all(&output_dir)
521        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
522
523    let admin_paths_path = if !inputs.admin_paths.is_empty() {
524        let p = workdir.path().join("admin-paths.txt");
525        std::fs::write(&p, inputs.admin_paths.join("\n"))
526            .map_err(|e| BenchError::Other(format!("Failed to write admin paths file: {}", e)))?;
527        Some(p)
528    } else {
529        None
530    };
531
532    let report_path = output_dir.join("owasp-report.json");
533    let cmd = BenchCommand {
534        spec: vec![spec_path],
535        target: inputs.target_url,
536        base_path: inputs.base_path,
537        vus: inputs.vus,
538        skip_tls_verify: inputs.skip_tls_verify,
539        headers: inputs.headers,
540        owasp_api_top10: true,
541        owasp_categories: inputs.categories,
542        owasp_auth_header: inputs.auth_header,
543        owasp_auth_token: inputs.auth_token,
544        owasp_admin_paths: admin_paths_path,
545        owasp_id_fields: inputs.id_fields,
546        owasp_report: Some(report_path),
547        owasp_report_format: inputs.report_format,
548        owasp_iterations: inputs.iterations,
549        ..default_bench_command(&output_dir)
550    };
551
552    cmd.execute().await?;
553    read_artifacts(&output_dir)
554}
555
556/// Run a payload-injection security test layered on a standard k6 bench.
557///
558/// Requires k6 on `$PATH`.
559pub async fn run_security(inputs: CloudSecurityInputs) -> Result<CloudRunArtifacts> {
560    if inputs.target_url.trim().is_empty() {
561        return Err(BenchError::Other("target_url is required".to_string()));
562    }
563    if inputs.spec_bytes.is_empty() {
564        return Err(BenchError::Other("spec_bytes is required for security runs".to_string()));
565    }
566    if !K6Executor::is_k6_installed() {
567        return Err(BenchError::K6NotFound);
568    }
569    enforce_ssrf(&inputs.target_url).await?;
570
571    let workdir = TempDir::new()
572        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
573    let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
574    let output_dir = workdir.path().join("output");
575    std::fs::create_dir_all(&output_dir)
576        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
577
578    let cmd = BenchCommand {
579        spec: vec![spec_path],
580        target: inputs.target_url,
581        base_path: inputs.base_path,
582        duration: inputs.duration,
583        vus: inputs.vus,
584        scenario: inputs.scenario,
585        auth: inputs.auth,
586        headers: inputs.headers,
587        skip_tls_verify: inputs.skip_tls_verify,
588        security_test: true,
589        security_categories: inputs.categories,
590        security_target_fields: inputs.target_fields,
591        ..default_bench_command(&output_dir)
592    };
593
594    cmd.execute().await?;
595    read_artifacts(&output_dir)
596}
597
598/// Run a WAFBench (OWASP CRS) coverage test.
599///
600/// Requires k6 on `$PATH` and the WAFBench rules accessible at
601/// [`CloudWafBenchInputs::rules_dir`] on the bench host.
602pub async fn run_wafbench(inputs: CloudWafBenchInputs) -> Result<CloudRunArtifacts> {
603    if inputs.target_url.trim().is_empty() {
604        return Err(BenchError::Other("target_url is required".to_string()));
605    }
606    if inputs.spec_bytes.is_empty() {
607        return Err(BenchError::Other("spec_bytes is required for WAFBench runs".to_string()));
608    }
609    if inputs.rules_dir.trim().is_empty() {
610        return Err(BenchError::Other(
611            "rules_dir is required for WAFBench runs (point at the bundled CRS install)"
612                .to_string(),
613        ));
614    }
615    if !K6Executor::is_k6_installed() {
616        return Err(BenchError::K6NotFound);
617    }
618    enforce_ssrf(&inputs.target_url).await?;
619
620    let workdir = TempDir::new()
621        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
622    let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
623    let output_dir = workdir.path().join("output");
624    std::fs::create_dir_all(&output_dir)
625        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
626
627    let cmd = BenchCommand {
628        spec: vec![spec_path],
629        target: inputs.target_url,
630        base_path: inputs.base_path,
631        duration: inputs.duration,
632        vus: inputs.vus,
633        scenario: inputs.scenario,
634        auth: inputs.auth,
635        headers: inputs.headers,
636        skip_tls_verify: inputs.skip_tls_verify,
637        wafbench_dir: Some(inputs.rules_dir),
638        wafbench_cycle_all: inputs.cycle_all,
639        ..default_bench_command(&output_dir)
640    };
641
642    cmd.execute().await?;
643    read_artifacts(&output_dir)
644}
645
646/// Run a CRUD flow test against [`CloudCrudFlowInputs::target_url`].
647///
648/// Requires k6 on `$PATH`.
649pub async fn run_crud_flow(inputs: CloudCrudFlowInputs) -> Result<CloudRunArtifacts> {
650    if inputs.target_url.trim().is_empty() {
651        return Err(BenchError::Other("target_url is required".to_string()));
652    }
653    if inputs.spec_bytes.is_empty() {
654        return Err(BenchError::Other("spec_bytes is required for CRUD flow runs".to_string()));
655    }
656    if !K6Executor::is_k6_installed() {
657        return Err(BenchError::K6NotFound);
658    }
659    enforce_ssrf(&inputs.target_url).await?;
660
661    let workdir = TempDir::new()
662        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
663    let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
664    let output_dir = workdir.path().join("output");
665    std::fs::create_dir_all(&output_dir)
666        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
667
668    let flow_config_path = if let Some(yaml) = &inputs.flow_config_yaml {
669        let p = workdir.path().join("flow-config.yaml");
670        std::fs::write(&p, yaml)
671            .map_err(|e| BenchError::Other(format!("Failed to write flow config: {}", e)))?;
672        Some(p)
673    } else {
674        None
675    };
676
677    let cmd = BenchCommand {
678        spec: vec![spec_path],
679        target: inputs.target_url,
680        base_path: inputs.base_path,
681        duration: inputs.duration,
682        vus: inputs.vus,
683        scenario: inputs.scenario,
684        auth: inputs.auth,
685        headers: inputs.headers,
686        skip_tls_verify: inputs.skip_tls_verify,
687        crud_flow: true,
688        flow_config: flow_config_path,
689        extract_fields: inputs.extract_fields,
690        ..default_bench_command(&output_dir)
691    };
692
693    cmd.execute().await?;
694    read_artifacts(&output_dir)
695}
696
697/// Build a [`BenchCommand`] populated with sensible defaults for a single-spec
698/// run targeting the given output directory.
699///
700/// Caller should overwrite the fields relevant to their run via
701/// `..default_bench_command(&output_dir)` struct update syntax.
702fn default_bench_command(output_dir: &Path) -> BenchCommand {
703    BenchCommand {
704        spec: Vec::new(),
705        spec_dir: None,
706        merge_conflicts: "error".to_string(),
707        spec_mode: "merge".to_string(),
708        dependency_config: None,
709        target: String::new(),
710        base_path: None,
711        duration: "30s".to_string(),
712        vus: 10,
713        target_rps: None,
714        no_keep_alive: false,
715        scenario: "constant".to_string(),
716        operations: None,
717        exclude_operations: None,
718        auth: None,
719        headers: Vec::new(),
720        output: output_dir.to_path_buf(),
721        generate_only: false,
722        script_output: None,
723        threshold_percentile: "p(95)".to_string(),
724        threshold_ms: 1000,
725        max_error_rate: 0.01,
726        verbose: false,
727        skip_tls_verify: false,
728        chunked_request_bodies: false,
729        targets_file: None,
730        max_concurrency: None,
731        results_format: "aggregated".to_string(),
732        params_file: None,
733        crud_flow: false,
734        flow_config: None,
735        extract_fields: None,
736        parallel_create: None,
737        data_file: None,
738        data_distribution: "unique-per-vu".to_string(),
739        data_mappings: None,
740        per_uri_control: false,
741        error_rate: None,
742        error_types: None,
743        security_test: false,
744        security_payloads: None,
745        security_categories: None,
746        security_target_fields: None,
747        wafbench_dir: None,
748        wafbench_cycle_all: false,
749        conformance: false,
750        conformance_api_key: None,
751        conformance_basic_auth: None,
752        conformance_report: output_dir.join("conformance-report.json"),
753        conformance_categories: None,
754        conformance_report_format: "json".to_string(),
755        conformance_headers: Vec::new(),
756        conformance_all_operations: false,
757        conformance_custom: None,
758        conformance_delay_ms: 0,
759        use_k6: false,
760        conformance_custom_filter: None,
761        export_requests: false,
762        validate_requests: false,
763        conformance_self_test: false,
764        conformance_self_test_capture: false,
765        validate_response_schemas: false,
766        source_ips: Vec::new(),
767        geo_source_ips: Vec::new(),
768        geo_source_headers: Vec::new(),
769        report_missed_cap: None,
770        owasp_api_top10: false,
771        owasp_categories: None,
772        owasp_auth_header: "Authorization".to_string(),
773        owasp_auth_token: None,
774        owasp_admin_paths: None,
775        owasp_id_fields: None,
776        owasp_report: None,
777        owasp_report_format: "json".to_string(),
778        owasp_iterations: 1,
779    }
780}
781
782fn write_spec(dir: &Path, bytes: &[u8], format: SpecFormat) -> Result<PathBuf> {
783    let filename = format!("spec.{}", format.extension(bytes));
784    let path = dir.join(filename);
785    std::fs::write(&path, bytes)
786        .map_err(|e| BenchError::Other(format!("Failed to write spec to tempdir: {}", e)))?;
787    Ok(path)
788}
789
790fn read_artifacts(output_dir: &Path) -> Result<CloudRunArtifacts> {
791    let mut files = HashMap::new();
792    if output_dir.exists() {
793        let entries = std::fs::read_dir(output_dir)
794            .map_err(|e| BenchError::Other(format!("Failed to read output dir: {}", e)))?;
795        for entry in entries {
796            let entry =
797                entry.map_err(|e| BenchError::Other(format!("Failed to read entry: {}", e)))?;
798            let metadata = entry
799                .metadata()
800                .map_err(|e| BenchError::Other(format!("Failed to stat entry: {}", e)))?;
801            if !metadata.is_file() {
802                continue;
803            }
804            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
805                continue;
806            };
807            let bytes = std::fs::read(entry.path()).map_err(|e| {
808                BenchError::Other(format!("Failed to read artifact {}: {}", name, e))
809            })?;
810            files.insert(name, bytes);
811        }
812    }
813
814    let k6_results = files.get("summary.json").and_then(|bytes| parse_k6_summary(bytes).ok());
815
816    Ok(CloudRunArtifacts { k6_results, files })
817}
818
819fn parse_k6_summary(bytes: &[u8]) -> Result<K6Results> {
820    let json: serde_json::Value =
821        serde_json::from_slice(bytes).map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
822    let duration_values = &json["metrics"]["http_req_duration"]["values"];
823    let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
824    let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
825    let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
826    let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
827    let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
828    // Counter that the template increments when res.timings.connecting > 0
829    // (i.e. a fresh TCP socket was established). See executor::parse_results
830    // for the reason we can't use the Trend's count.
831    let mf_conns_opened = &json["metrics"]["mockforge_connections_opened"]["values"]["count"];
832    Ok(K6Results {
833        total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
834        // See `K6Executor::parse_results` for the rationale on why
835        // http_req_failed.passes is the failure count.
836        failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
837            .as_u64()
838            .unwrap_or(0),
839        avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
840        p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
841        p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
842        rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
843        vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
844        min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
845        max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
846        med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
847        p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
848        server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
849        server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
850        server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
851        server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
852        server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
853        server_reported_faults: server_fault.as_u64().unwrap_or(0),
854        tcp_connect_samples: mf_conns_opened.as_u64().unwrap_or(0),
855        tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
856        tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
857        tls_handshake_samples: if tls_handshake["avg"].as_f64().unwrap_or(0.0) > 0.0 {
858            mf_conns_opened.as_u64().unwrap_or(0)
859        } else {
860            0
861        },
862        tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
863        tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
864        iterations_completed: json["metrics"]["iterations"]["values"]["count"]
865            .as_u64()
866            .unwrap_or(0),
867    })
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn spec_format_extension_for_json_bytes() {
876        assert_eq!(SpecFormat::Auto.extension(b"  {\"openapi\":\"3.0.0\"}"), "json");
877        assert_eq!(SpecFormat::Auto.extension(b"openapi: 3.0.0\n"), "yaml");
878        assert_eq!(SpecFormat::Json.extension(b"openapi: 3.0.0"), "json");
879        assert_eq!(SpecFormat::Yaml.extension(b"{}"), "yaml");
880    }
881
882    #[test]
883    fn write_spec_round_trips_bytes() {
884        let dir = TempDir::new().unwrap();
885        let path = write_spec(dir.path(), b"openapi: 3.0.0\n", SpecFormat::Yaml).unwrap();
886        assert!(path.ends_with("spec.yaml"));
887        let read_back = std::fs::read(&path).unwrap();
888        assert_eq!(read_back, b"openapi: 3.0.0\n");
889    }
890
891    #[test]
892    fn read_artifacts_collects_top_level_files_only() {
893        let dir = TempDir::new().unwrap();
894        let out = dir.path();
895        std::fs::write(out.join("summary.json"), br#"{"metrics":{}}"#).unwrap();
896        std::fs::write(out.join("k6-output.log"), b"hello").unwrap();
897        // Subdirectory should be ignored.
898        std::fs::create_dir(out.join("nested")).unwrap();
899        std::fs::write(out.join("nested").join("ignored.txt"), b"nope").unwrap();
900
901        let artifacts = read_artifacts(out).unwrap();
902        assert_eq!(artifacts.files.len(), 2);
903        assert!(artifacts.files.contains_key("summary.json"));
904        assert!(artifacts.files.contains_key("k6-output.log"));
905        assert!(!artifacts.files.contains_key("ignored.txt"));
906    }
907
908    #[test]
909    fn parse_k6_summary_handles_minimal_input() {
910        let bytes = br#"{"metrics":{}}"#;
911        let r = parse_k6_summary(bytes).unwrap();
912        assert_eq!(r.total_requests, 0);
913        assert_eq!(r.failed_requests, 0);
914        assert_eq!(r.error_rate(), 0.0);
915    }
916
917    #[test]
918    fn parse_k6_summary_extracts_values() {
919        let bytes = br#"{
920            "metrics": {
921                "http_reqs": {"values": {"count": 100, "rate": 33.5}},
922                "http_req_failed": {"values": {"passes": 4}},
923                "http_req_duration": {"values": {
924                    "avg": 12.3, "med": 10.0, "min": 1.0, "max": 50.0,
925                    "p(90)": 20.0, "p(95)": 25.0, "p(99)": 40.0
926                }},
927                "vus_max": {"values": {"value": 10}}
928            }
929        }"#;
930        let r = parse_k6_summary(bytes).unwrap();
931        assert_eq!(r.total_requests, 100);
932        assert_eq!(r.failed_requests, 4);
933        assert_eq!(r.rps, 33.5);
934        assert_eq!(r.p95_duration_ms, 25.0);
935        assert_eq!(r.vus_max, 10);
936    }
937
938    #[test]
939    fn cloud_run_artifacts_get_helpers() {
940        let mut a = CloudRunArtifacts::default();
941        a.files.insert("hello.txt".to_string(), b"world".to_vec());
942        a.files.insert("payload.json".to_string(), br#"{"x":1}"#.to_vec());
943
944        assert_eq!(a.get("hello.txt").unwrap(), b"world");
945        assert_eq!(a.get_string("hello.txt").unwrap(), "world");
946        assert_eq!(a.get_json("payload.json").unwrap()["x"], 1);
947        assert!(a.get("missing").is_none());
948    }
949
950    #[tokio::test]
951    async fn run_bench_rejects_empty_target() {
952        let inputs = CloudBenchInputs {
953            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
954            ..Default::default()
955        };
956        let err = run_bench(inputs).await.unwrap_err();
957        assert!(matches!(err, BenchError::Other(_)));
958    }
959
960    #[tokio::test]
961    async fn run_bench_rejects_empty_spec() {
962        let inputs = CloudBenchInputs {
963            target_url: "https://example.com".to_string(),
964            ..Default::default()
965        };
966        let err = run_bench(inputs).await.unwrap_err();
967        assert!(matches!(err, BenchError::Other(_)));
968    }
969
970    #[tokio::test]
971    async fn run_conformance_rejects_empty_target() {
972        let inputs = CloudConformanceInputs::default();
973        let err = run_conformance(inputs).await.unwrap_err();
974        assert!(matches!(err, BenchError::Other(_)));
975    }
976
977    #[tokio::test]
978    async fn run_owasp_rejects_missing_inputs() {
979        let no_target = run_owasp(CloudOwaspInputs {
980            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
981            ..Default::default()
982        })
983        .await
984        .unwrap_err();
985        assert!(matches!(no_target, BenchError::Other(_)));
986
987        let no_spec = run_owasp(CloudOwaspInputs {
988            target_url: "https://example.com".to_string(),
989            ..Default::default()
990        })
991        .await
992        .unwrap_err();
993        assert!(matches!(no_spec, BenchError::Other(_)));
994    }
995
996    #[tokio::test]
997    async fn run_security_rejects_missing_inputs() {
998        let err = run_security(CloudSecurityInputs::default()).await.unwrap_err();
999        assert!(matches!(err, BenchError::Other(_)));
1000    }
1001
1002    #[tokio::test]
1003    async fn run_wafbench_rejects_missing_rules_dir() {
1004        let err = run_wafbench(CloudWafBenchInputs {
1005            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
1006            target_url: "https://example.com".to_string(),
1007            ..Default::default()
1008        })
1009        .await
1010        .unwrap_err();
1011        let BenchError::Other(msg) = err else {
1012            panic!("expected BenchError::Other");
1013        };
1014        assert!(msg.contains("rules_dir"), "got: {msg}");
1015    }
1016
1017    #[tokio::test]
1018    async fn run_crud_flow_rejects_missing_inputs() {
1019        let err = run_crud_flow(CloudCrudFlowInputs::default()).await.unwrap_err();
1020        assert!(matches!(err, BenchError::Other(_)));
1021    }
1022}