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