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        abort_on_error: true,
727        abort_on_error_rate: 0.95,
728        verbose: false,
729        skip_tls_verify: false,
730        chunked_request_bodies: false,
731        targets_file: None,
732        max_concurrency: None,
733        results_format: "aggregated".to_string(),
734        params_file: None,
735        crud_flow: false,
736        flow_config: None,
737        extract_fields: None,
738        parallel_create: None,
739        data_file: None,
740        data_distribution: "unique-per-vu".to_string(),
741        data_mappings: None,
742        per_uri_control: false,
743        error_rate: None,
744        error_types: None,
745        security_test: false,
746        security_payloads: None,
747        security_categories: None,
748        security_target_fields: None,
749        wafbench_dir: None,
750        wafbench_cycle_all: false,
751        conformance: false,
752        conformance_api_key: None,
753        conformance_basic_auth: None,
754        conformance_report: output_dir.join("conformance-report.json"),
755        conformance_categories: None,
756        conformance_report_format: "json".to_string(),
757        conformance_headers: Vec::new(),
758        conformance_all_operations: false,
759        conformance_custom: None,
760        conformance_delay_ms: 0,
761        use_k6: false,
762        conformance_custom_filter: None,
763        export_requests: false,
764        validate_requests: false,
765        conformance_self_test: false,
766        conformance_self_test_capture: false,
767        conformance_self_test_iterations: 1,
768        conformance_self_test_duration: None,
769        validate_response_schemas: false,
770        source_ips: Vec::new(),
771        geo_source_ips: Vec::new(),
772        geo_source_headers: Vec::new(),
773        report_missed_cap: None,
774        discard_response_bodies: false,
775        dns_policy: None,
776        owasp_api_top10: false,
777        owasp_categories: None,
778        owasp_auth_header: "Authorization".to_string(),
779        owasp_auth_token: None,
780        owasp_admin_paths: None,
781        owasp_id_fields: None,
782        owasp_report: None,
783        owasp_report_format: "json".to_string(),
784        owasp_iterations: 1,
785    }
786}
787
788fn write_spec(dir: &Path, bytes: &[u8], format: SpecFormat) -> Result<PathBuf> {
789    let filename = format!("spec.{}", format.extension(bytes));
790    let path = dir.join(filename);
791    std::fs::write(&path, bytes)
792        .map_err(|e| BenchError::Other(format!("Failed to write spec to tempdir: {}", e)))?;
793    Ok(path)
794}
795
796fn read_artifacts(output_dir: &Path) -> Result<CloudRunArtifacts> {
797    let mut files = HashMap::new();
798    if output_dir.exists() {
799        let entries = std::fs::read_dir(output_dir)
800            .map_err(|e| BenchError::Other(format!("Failed to read output dir: {}", e)))?;
801        for entry in entries {
802            let entry =
803                entry.map_err(|e| BenchError::Other(format!("Failed to read entry: {}", e)))?;
804            let metadata = entry
805                .metadata()
806                .map_err(|e| BenchError::Other(format!("Failed to stat entry: {}", e)))?;
807            if !metadata.is_file() {
808                continue;
809            }
810            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
811                continue;
812            };
813            let bytes = std::fs::read(entry.path()).map_err(|e| {
814                BenchError::Other(format!("Failed to read artifact {}: {}", name, e))
815            })?;
816            files.insert(name, bytes);
817        }
818    }
819
820    let k6_results = files.get("summary.json").and_then(|bytes| parse_k6_summary(bytes).ok());
821
822    Ok(CloudRunArtifacts { k6_results, files })
823}
824
825fn parse_k6_summary(bytes: &[u8]) -> Result<K6Results> {
826    let json: serde_json::Value =
827        serde_json::from_slice(bytes).map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
828    let duration_values = &json["metrics"]["http_req_duration"]["values"];
829    let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
830    let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
831    let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
832    let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
833    let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
834    // Counter that the template increments when res.timings.connecting > 0
835    // (i.e. a fresh TCP socket was established). See executor::parse_results
836    // for the reason we can't use the Trend's count.
837    let mf_conns_opened = &json["metrics"]["mockforge_connections_opened"]["values"]["count"];
838    Ok(K6Results {
839        total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
840        // See `K6Executor::parse_results` for the rationale on why
841        // http_req_failed.passes is the failure count.
842        failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
843            .as_u64()
844            .unwrap_or(0),
845        avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
846        p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
847        p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
848        rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
849        vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
850        min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
851        max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
852        med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
853        p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
854        server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
855        server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
856        server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
857        server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
858        server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
859        server_reported_faults: server_fault.as_u64().unwrap_or(0),
860        tcp_connect_samples: mf_conns_opened.as_u64().unwrap_or(0),
861        tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
862        tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
863        tls_handshake_samples: if tls_handshake["avg"].as_f64().unwrap_or(0.0) > 0.0 {
864            mf_conns_opened.as_u64().unwrap_or(0)
865        } else {
866            0
867        },
868        tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
869        tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
870        iterations_completed: json["metrics"]["iterations"]["values"]["count"]
871            .as_u64()
872            .unwrap_or(0),
873    })
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn spec_format_extension_for_json_bytes() {
882        assert_eq!(SpecFormat::Auto.extension(b"  {\"openapi\":\"3.0.0\"}"), "json");
883        assert_eq!(SpecFormat::Auto.extension(b"openapi: 3.0.0\n"), "yaml");
884        assert_eq!(SpecFormat::Json.extension(b"openapi: 3.0.0"), "json");
885        assert_eq!(SpecFormat::Yaml.extension(b"{}"), "yaml");
886    }
887
888    #[test]
889    fn write_spec_round_trips_bytes() {
890        let dir = TempDir::new().unwrap();
891        let path = write_spec(dir.path(), b"openapi: 3.0.0\n", SpecFormat::Yaml).unwrap();
892        assert!(path.ends_with("spec.yaml"));
893        let read_back = std::fs::read(&path).unwrap();
894        assert_eq!(read_back, b"openapi: 3.0.0\n");
895    }
896
897    #[test]
898    fn read_artifacts_collects_top_level_files_only() {
899        let dir = TempDir::new().unwrap();
900        let out = dir.path();
901        std::fs::write(out.join("summary.json"), br#"{"metrics":{}}"#).unwrap();
902        std::fs::write(out.join("k6-output.log"), b"hello").unwrap();
903        // Subdirectory should be ignored.
904        std::fs::create_dir(out.join("nested")).unwrap();
905        std::fs::write(out.join("nested").join("ignored.txt"), b"nope").unwrap();
906
907        let artifacts = read_artifacts(out).unwrap();
908        assert_eq!(artifacts.files.len(), 2);
909        assert!(artifacts.files.contains_key("summary.json"));
910        assert!(artifacts.files.contains_key("k6-output.log"));
911        assert!(!artifacts.files.contains_key("ignored.txt"));
912    }
913
914    #[test]
915    fn parse_k6_summary_handles_minimal_input() {
916        let bytes = br#"{"metrics":{}}"#;
917        let r = parse_k6_summary(bytes).unwrap();
918        assert_eq!(r.total_requests, 0);
919        assert_eq!(r.failed_requests, 0);
920        assert_eq!(r.error_rate(), 0.0);
921    }
922
923    #[test]
924    fn parse_k6_summary_extracts_values() {
925        let bytes = br#"{
926            "metrics": {
927                "http_reqs": {"values": {"count": 100, "rate": 33.5}},
928                "http_req_failed": {"values": {"passes": 4}},
929                "http_req_duration": {"values": {
930                    "avg": 12.3, "med": 10.0, "min": 1.0, "max": 50.0,
931                    "p(90)": 20.0, "p(95)": 25.0, "p(99)": 40.0
932                }},
933                "vus_max": {"values": {"value": 10}}
934            }
935        }"#;
936        let r = parse_k6_summary(bytes).unwrap();
937        assert_eq!(r.total_requests, 100);
938        assert_eq!(r.failed_requests, 4);
939        assert_eq!(r.rps, 33.5);
940        assert_eq!(r.p95_duration_ms, 25.0);
941        assert_eq!(r.vus_max, 10);
942    }
943
944    #[test]
945    fn cloud_run_artifacts_get_helpers() {
946        let mut a = CloudRunArtifacts::default();
947        a.files.insert("hello.txt".to_string(), b"world".to_vec());
948        a.files.insert("payload.json".to_string(), br#"{"x":1}"#.to_vec());
949
950        assert_eq!(a.get("hello.txt").unwrap(), b"world");
951        assert_eq!(a.get_string("hello.txt").unwrap(), "world");
952        assert_eq!(a.get_json("payload.json").unwrap()["x"], 1);
953        assert!(a.get("missing").is_none());
954    }
955
956    #[tokio::test]
957    async fn run_bench_rejects_empty_target() {
958        let inputs = CloudBenchInputs {
959            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
960            ..Default::default()
961        };
962        let err = run_bench(inputs).await.unwrap_err();
963        assert!(matches!(err, BenchError::Other(_)));
964    }
965
966    #[tokio::test]
967    async fn run_bench_rejects_empty_spec() {
968        let inputs = CloudBenchInputs {
969            target_url: "https://example.com".to_string(),
970            ..Default::default()
971        };
972        let err = run_bench(inputs).await.unwrap_err();
973        assert!(matches!(err, BenchError::Other(_)));
974    }
975
976    #[tokio::test]
977    async fn run_conformance_rejects_empty_target() {
978        let inputs = CloudConformanceInputs::default();
979        let err = run_conformance(inputs).await.unwrap_err();
980        assert!(matches!(err, BenchError::Other(_)));
981    }
982
983    #[tokio::test]
984    async fn run_owasp_rejects_missing_inputs() {
985        let no_target = run_owasp(CloudOwaspInputs {
986            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
987            ..Default::default()
988        })
989        .await
990        .unwrap_err();
991        assert!(matches!(no_target, BenchError::Other(_)));
992
993        let no_spec = run_owasp(CloudOwaspInputs {
994            target_url: "https://example.com".to_string(),
995            ..Default::default()
996        })
997        .await
998        .unwrap_err();
999        assert!(matches!(no_spec, BenchError::Other(_)));
1000    }
1001
1002    #[tokio::test]
1003    async fn run_security_rejects_missing_inputs() {
1004        let err = run_security(CloudSecurityInputs::default()).await.unwrap_err();
1005        assert!(matches!(err, BenchError::Other(_)));
1006    }
1007
1008    #[tokio::test]
1009    async fn run_wafbench_rejects_missing_rules_dir() {
1010        let err = run_wafbench(CloudWafBenchInputs {
1011            spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
1012            target_url: "https://example.com".to_string(),
1013            ..Default::default()
1014        })
1015        .await
1016        .unwrap_err();
1017        let BenchError::Other(msg) = err else {
1018            panic!("expected BenchError::Other");
1019        };
1020        assert!(msg.contains("rules_dir"), "got: {msg}");
1021    }
1022
1023    #[tokio::test]
1024    async fn run_crud_flow_rejects_missing_inputs() {
1025        let err = run_crud_flow(CloudCrudFlowInputs::default()).await.unwrap_err();
1026        assert!(matches!(err, BenchError::Other(_)));
1027    }
1028}