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        wafbench_verbatim: false,
640        ..default_bench_command(&output_dir)
641    };
642
643    cmd.execute().await?;
644    read_artifacts(&output_dir)
645}
646
647/// Run a CRUD flow test against [`CloudCrudFlowInputs::target_url`].
648///
649/// Requires k6 on `$PATH`.
650pub async fn run_crud_flow(inputs: CloudCrudFlowInputs) -> Result<CloudRunArtifacts> {
651    if inputs.target_url.trim().is_empty() {
652        return Err(BenchError::Other("target_url is required".to_string()));
653    }
654    if inputs.spec_bytes.is_empty() {
655        return Err(BenchError::Other("spec_bytes is required for CRUD flow runs".to_string()));
656    }
657    if !K6Executor::is_k6_installed() {
658        return Err(BenchError::K6NotFound);
659    }
660    enforce_ssrf(&inputs.target_url).await?;
661
662    let workdir = TempDir::new()
663        .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
664    let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
665    let output_dir = workdir.path().join("output");
666    std::fs::create_dir_all(&output_dir)
667        .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
668
669    let flow_config_path = if let Some(yaml) = &inputs.flow_config_yaml {
670        let p = workdir.path().join("flow-config.yaml");
671        std::fs::write(&p, yaml)
672            .map_err(|e| BenchError::Other(format!("Failed to write flow config: {}", e)))?;
673        Some(p)
674    } else {
675        None
676    };
677
678    let cmd = BenchCommand {
679        spec: vec![spec_path],
680        target: inputs.target_url,
681        base_path: inputs.base_path,
682        duration: inputs.duration,
683        vus: inputs.vus,
684        scenario: inputs.scenario,
685        auth: inputs.auth,
686        headers: inputs.headers,
687        skip_tls_verify: inputs.skip_tls_verify,
688        crud_flow: true,
689        flow_config: flow_config_path,
690        extract_fields: inputs.extract_fields,
691        ..default_bench_command(&output_dir)
692    };
693
694    cmd.execute().await?;
695    read_artifacts(&output_dir)
696}
697
698/// Build a [`BenchCommand`] populated with sensible defaults for a single-spec
699/// run targeting the given output directory.
700///
701/// Caller should overwrite the fields relevant to their run via
702/// `..default_bench_command(&output_dir)` struct update syntax.
703fn default_bench_command(output_dir: &Path) -> BenchCommand {
704    BenchCommand {
705        spec: Vec::new(),
706        spec_dir: None,
707        merge_conflicts: "error".to_string(),
708        spec_mode: "merge".to_string(),
709        dependency_config: None,
710        target: String::new(),
711        base_path: None,
712        duration: "30s".to_string(),
713        vus: 10,
714        target_rps: None,
715        no_keep_alive: false,
716        scenario: "constant".to_string(),
717        operations: None,
718        exclude_operations: None,
719        auth: None,
720        headers: Vec::new(),
721        output: output_dir.to_path_buf(),
722        generate_only: false,
723        script_output: None,
724        threshold_percentile: "p(95)".to_string(),
725        threshold_ms: 1000,
726        max_error_rate: 0.01,
727        abort_on_error: true,
728        abort_on_error_rate: 0.95,
729        per_op_metrics: None,
730        verbose: false,
731        skip_tls_verify: false,
732        chunked_request_bodies: false,
733        targets_file: None,
734        max_concurrency: None,
735        results_format: "aggregated".to_string(),
736        params_file: None,
737        crud_flow: false,
738        flow_config: None,
739        extract_fields: None,
740        parallel_create: None,
741        data_file: None,
742        data_distribution: "unique-per-vu".to_string(),
743        data_mappings: None,
744        per_uri_control: false,
745        error_rate: None,
746        error_types: None,
747        security_test: false,
748        security_payloads: None,
749        security_categories: None,
750        security_target_fields: None,
751        wafbench_dir: None,
752        wafbench_cycle_all: false,
753        wafbench_verbatim: false,
754        conformance: false,
755        conformance_api_key: None,
756        conformance_basic_auth: None,
757        conformance_report: output_dir.join("conformance-report.json"),
758        conformance_categories: None,
759        conformance_report_format: "json".to_string(),
760        conformance_headers: Vec::new(),
761        conformance_all_operations: false,
762        conformance_custom: None,
763        conformance_delay_ms: 0,
764        use_k6: false,
765        conformance_custom_filter: None,
766        export_requests: false,
767        validate_requests: false,
768        conformance_self_test: false,
769        conformance_self_test_capture: false,
770        conformance_self_test_iterations: 1,
771        conformance_self_test_duration: None,
772        validate_response_schemas: false,
773        source_ips: Vec::new(),
774        geo_source_ips: Vec::new(),
775        geo_source_headers: Vec::new(),
776        report_missed_cap: None,
777        discard_response_bodies: false,
778        dns_policy: None,
779        owasp_api_top10: false,
780        owasp_categories: None,
781        owasp_auth_header: "Authorization".to_string(),
782        owasp_auth_token: None,
783        owasp_admin_paths: None,
784        owasp_id_fields: None,
785        owasp_report: None,
786        owasp_report_format: "json".to_string(),
787        owasp_iterations: 1,
788    }
789}
790
791fn write_spec(dir: &Path, bytes: &[u8], format: SpecFormat) -> Result<PathBuf> {
792    let filename = format!("spec.{}", format.extension(bytes));
793    let path = dir.join(filename);
794    std::fs::write(&path, bytes)
795        .map_err(|e| BenchError::Other(format!("Failed to write spec to tempdir: {}", e)))?;
796    Ok(path)
797}
798
799fn read_artifacts(output_dir: &Path) -> Result<CloudRunArtifacts> {
800    let mut files = HashMap::new();
801    if output_dir.exists() {
802        let entries = std::fs::read_dir(output_dir)
803            .map_err(|e| BenchError::Other(format!("Failed to read output dir: {}", e)))?;
804        for entry in entries {
805            let entry =
806                entry.map_err(|e| BenchError::Other(format!("Failed to read entry: {}", e)))?;
807            let metadata = entry
808                .metadata()
809                .map_err(|e| BenchError::Other(format!("Failed to stat entry: {}", e)))?;
810            if !metadata.is_file() {
811                continue;
812            }
813            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
814                continue;
815            };
816            let bytes = std::fs::read(entry.path()).map_err(|e| {
817                BenchError::Other(format!("Failed to read artifact {}: {}", name, e))
818            })?;
819            files.insert(name, bytes);
820        }
821    }
822
823    let k6_results = files.get("summary.json").and_then(|bytes| parse_k6_summary(bytes).ok());
824
825    Ok(CloudRunArtifacts { k6_results, files })
826}
827
828fn parse_k6_summary(bytes: &[u8]) -> Result<K6Results> {
829    let json: serde_json::Value =
830        serde_json::from_slice(bytes).map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
831    let duration_values = &json["metrics"]["http_req_duration"]["values"];
832    let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
833    let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
834    let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
835    let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
836    let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
837    // Counter that the template increments when res.timings.connecting > 0
838    // (i.e. a fresh TCP socket was established). See executor::parse_results
839    // for the reason we can't use the Trend's count.
840    let mf_conns_opened = &json["metrics"]["mockforge_connections_opened"]["values"]["count"];
841    Ok(K6Results {
842        total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
843        // See `K6Executor::parse_results` for the rationale on why
844        // http_req_failed.passes is the failure count.
845        failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
846            .as_u64()
847            .unwrap_or(0),
848        avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
849        p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
850        p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
851        rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
852        vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
853        min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
854        max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
855        med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
856        p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
857        server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
858        server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
859        server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
860        server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
861        server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
862        server_reported_faults: server_fault.as_u64().unwrap_or(0),
863        tcp_connect_samples: mf_conns_opened.as_u64().unwrap_or(0),
864        tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
865        tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
866        tls_handshake_samples: if tls_handshake["avg"].as_f64().unwrap_or(0.0) > 0.0 {
867            mf_conns_opened.as_u64().unwrap_or(0)
868        } else {
869            0
870        },
871        tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
872        tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
873        iterations_completed: json["metrics"]["iterations"]["values"]["count"]
874            .as_u64()
875            .unwrap_or(0),
876    })
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    #[test]
884    fn spec_format_extension_for_json_bytes() {
885        assert_eq!(SpecFormat::Auto.extension(b"  {\"openapi\":\"3.0.0\"}"), "json");
886        assert_eq!(SpecFormat::Auto.extension(b"openapi: 3.0.0\n"), "yaml");
887        assert_eq!(SpecFormat::Json.extension(b"openapi: 3.0.0"), "json");
888        assert_eq!(SpecFormat::Yaml.extension(b"{}"), "yaml");
889    }
890
891    #[test]
892    fn write_spec_round_trips_bytes() {
893        let dir = TempDir::new().unwrap();
894        let path = write_spec(dir.path(), b"openapi: 3.0.0\n", SpecFormat::Yaml).unwrap();
895        assert!(path.ends_with("spec.yaml"));
896        let read_back = std::fs::read(&path).unwrap();
897        assert_eq!(read_back, b"openapi: 3.0.0\n");
898    }
899
900    #[test]
901    fn read_artifacts_collects_top_level_files_only() {
902        let dir = TempDir::new().unwrap();
903        let out = dir.path();
904        std::fs::write(out.join("summary.json"), br#"{"metrics":{}}"#).unwrap();
905        std::fs::write(out.join("k6-output.log"), b"hello").unwrap();
906        // Subdirectory should be ignored.
907        std::fs::create_dir(out.join("nested")).unwrap();
908        std::fs::write(out.join("nested").join("ignored.txt"), b"nope").unwrap();
909
910        let artifacts = read_artifacts(out).unwrap();
911        assert_eq!(artifacts.files.len(), 2);
912        assert!(artifacts.files.contains_key("summary.json"));
913        assert!(artifacts.files.contains_key("k6-output.log"));
914        assert!(!artifacts.files.contains_key("ignored.txt"));
915    }
916
917    #[test]
918    fn parse_k6_summary_handles_minimal_input() {
919        let bytes = br#"{"metrics":{}}"#;
920        let r = parse_k6_summary(bytes).unwrap();
921        assert_eq!(r.total_requests, 0);
922        assert_eq!(r.failed_requests, 0);
923        assert_eq!(r.error_rate(), 0.0);
924    }
925
926    #[test]
927    fn parse_k6_summary_extracts_values() {
928        let bytes = br#"{
929            "metrics": {
930                "http_reqs": {"values": {"count": 100, "rate": 33.5}},
931                "http_req_failed": {"values": {"passes": 4}},
932                "http_req_duration": {"values": {
933                    "avg": 12.3, "med": 10.0, "min": 1.0, "max": 50.0,
934                    "p(90)": 20.0, "p(95)": 25.0, "p(99)": 40.0
935                }},
936                "vus_max": {"values": {"value": 10}}
937            }
938        }"#;
939        let r = parse_k6_summary(bytes).unwrap();
940        assert_eq!(r.total_requests, 100);
941        assert_eq!(r.failed_requests, 4);
942        assert_eq!(r.rps, 33.5);
943        assert_eq!(r.p95_duration_ms, 25.0);
944        assert_eq!(r.vus_max, 10);
945    }
946
947    #[test]
948    fn cloud_run_artifacts_get_helpers() {
949        let mut a = CloudRunArtifacts::default();
950        a.files.insert("hello.txt".to_string(), b"world".to_vec());
951        a.files.insert("payload.json".to_string(), br#"{"x":1}"#.to_vec());
952
953        assert_eq!(a.get("hello.txt").unwrap(), b"world");
954        assert_eq!(a.get_string("hello.txt").unwrap(), "world");
955        assert_eq!(a.get_json("payload.json").unwrap()["x"], 1);
956        assert!(a.get("missing").is_none());
957    }
958
959    #[tokio::test]
960    async fn run_bench_rejects_empty_target() {
961        let inputs = CloudBenchInputs {
962            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
963            ..Default::default()
964        };
965        let err = run_bench(inputs).await.unwrap_err();
966        assert!(matches!(err, BenchError::Other(_)));
967    }
968
969    #[tokio::test]
970    async fn run_bench_rejects_empty_spec() {
971        let inputs = CloudBenchInputs {
972            target_url: "https://example.com".to_string(),
973            ..Default::default()
974        };
975        let err = run_bench(inputs).await.unwrap_err();
976        assert!(matches!(err, BenchError::Other(_)));
977    }
978
979    #[tokio::test]
980    async fn run_conformance_rejects_empty_target() {
981        let inputs = CloudConformanceInputs::default();
982        let err = run_conformance(inputs).await.unwrap_err();
983        assert!(matches!(err, BenchError::Other(_)));
984    }
985
986    #[tokio::test]
987    async fn run_owasp_rejects_missing_inputs() {
988        let no_target = run_owasp(CloudOwaspInputs {
989            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
990            ..Default::default()
991        })
992        .await
993        .unwrap_err();
994        assert!(matches!(no_target, BenchError::Other(_)));
995
996        let no_spec = run_owasp(CloudOwaspInputs {
997            target_url: "https://example.com".to_string(),
998            ..Default::default()
999        })
1000        .await
1001        .unwrap_err();
1002        assert!(matches!(no_spec, BenchError::Other(_)));
1003    }
1004
1005    #[tokio::test]
1006    async fn run_security_rejects_missing_inputs() {
1007        let err = run_security(CloudSecurityInputs::default()).await.unwrap_err();
1008        assert!(matches!(err, BenchError::Other(_)));
1009    }
1010
1011    #[tokio::test]
1012    async fn run_wafbench_rejects_missing_rules_dir() {
1013        let err = run_wafbench(CloudWafBenchInputs {
1014            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
1015            target_url: "https://example.com".to_string(),
1016            ..Default::default()
1017        })
1018        .await
1019        .unwrap_err();
1020        let BenchError::Other(msg) = err else {
1021            panic!("expected BenchError::Other");
1022        };
1023        assert!(msg.contains("rules_dir"), "got: {msg}");
1024    }
1025
1026    #[tokio::test]
1027    async fn run_crud_flow_rejects_missing_inputs() {
1028        let err = run_crud_flow(CloudCrudFlowInputs::default()).await.unwrap_err();
1029        assert!(matches!(err, BenchError::Other(_)));
1030    }
1031}