Skip to main content

mockforge_bench/conformance/
request_validator.rs

1//! Request validation against OpenAPI spec.
2//!
3//! Validates that conformance test requests (especially from HAR custom checks)
4//! conform to the OpenAPI specification: correct paths, required parameters,
5//! valid request body schemas, and matching content types.
6
7use crate::error::Result;
8use crate::spec_parser::SpecParser;
9use openapiv3::{OpenAPI, ReferenceOr};
10use serde::Serialize;
11use std::collections::HashMap;
12use std::path::Path;
13
14use super::custom::CustomConformanceConfig;
15
16/// A single request validation violation
17#[derive(Debug, Serialize)]
18pub struct RequestViolation {
19    /// Check name from the custom YAML
20    pub check_name: String,
21    /// Request method
22    pub method: String,
23    /// Request path
24    pub path: String,
25    /// Type of violation
26    pub violation_type: String,
27    /// Human-readable description
28    pub message: String,
29}
30
31/// Validate custom conformance checks against an OpenAPI spec.
32///
33/// Returns a list of violations (empty if all checks are valid).
34pub fn validate_custom_checks(
35    spec: &OpenAPI,
36    custom_checks_file: &Path,
37    base_path: Option<&str>,
38) -> Result<Vec<RequestViolation>> {
39    let config = CustomConformanceConfig::from_file(custom_checks_file)?;
40    let mut violations = Vec::new();
41
42    // Build a map of spec paths -> operations for matching
43    let spec_ops = build_spec_operation_map(spec);
44
45    for check in &config.custom_checks {
46        // Strip query string from path for matching
47        let check_path = check.path.split('?').next().unwrap_or(&check.path);
48
49        // Try to match the check's path to a spec operation
50        let spec_path = match find_matching_spec_path(check_path, &spec_ops, base_path) {
51            Some(p) => p,
52            None => {
53                violations.push(RequestViolation {
54                    check_name: check.name.clone(),
55                    method: check.method.clone(),
56                    path: check.path.clone(),
57                    violation_type: "unknown_path".to_string(),
58                    message: format!(
59                        "Path '{}' not found in OpenAPI spec (checked with base_path={:?})",
60                        check_path, base_path
61                    ),
62                });
63                continue;
64            }
65        };
66
67        // Check if the method is defined for this path
68        let path_item = match spec.paths.paths.get(&spec_path) {
69            Some(ReferenceOr::Item(item)) => item,
70            _ => continue,
71        };
72
73        let method_lower = check.method.to_lowercase();
74        let operation = match method_lower.as_str() {
75            "get" => path_item.get.as_ref(),
76            "post" => path_item.post.as_ref(),
77            "put" => path_item.put.as_ref(),
78            "delete" => path_item.delete.as_ref(),
79            "patch" => path_item.patch.as_ref(),
80            "head" => path_item.head.as_ref(),
81            "options" => path_item.options.as_ref(),
82            _ => None,
83        };
84
85        let operation = match operation {
86            Some(op) => op,
87            None => {
88                violations.push(RequestViolation {
89                    check_name: check.name.clone(),
90                    method: check.method.clone(),
91                    path: check.path.clone(),
92                    violation_type: "method_not_allowed".to_string(),
93                    message: format!(
94                        "Method '{}' not defined for path '{}' in the spec",
95                        check.method, spec_path
96                    ),
97                });
98                continue;
99            }
100        };
101
102        // Validate request body for POST/PUT/PATCH
103        if matches!(method_lower.as_str(), "post" | "put" | "patch") {
104            validate_request_body(
105                &check.name,
106                &check.method,
107                &check.path,
108                check.body.as_deref(),
109                operation,
110                spec,
111                &mut violations,
112            );
113        }
114
115        // Check required parameters
116        validate_parameters(
117            &check.name,
118            &check.method,
119            &check.path,
120            check_path,
121            &check.headers,
122            operation,
123            path_item,
124            spec,
125            &mut violations,
126        );
127    }
128
129    Ok(violations)
130}
131
132/// Collected spec operations indexed by path
133type SpecOperationMap = HashMap<String, Vec<String>>; // path -> [methods]
134
135fn build_spec_operation_map(spec: &OpenAPI) -> SpecOperationMap {
136    let mut map = HashMap::new();
137    for (path, item_ref) in &spec.paths.paths {
138        if let ReferenceOr::Item(item) = item_ref {
139            let mut methods = Vec::new();
140            if item.get.is_some() {
141                methods.push("GET".to_string());
142            }
143            if item.post.is_some() {
144                methods.push("POST".to_string());
145            }
146            if item.put.is_some() {
147                methods.push("PUT".to_string());
148            }
149            if item.delete.is_some() {
150                methods.push("DELETE".to_string());
151            }
152            if item.patch.is_some() {
153                methods.push("PATCH".to_string());
154            }
155            if item.head.is_some() {
156                methods.push("HEAD".to_string());
157            }
158            if item.options.is_some() {
159                methods.push("OPTIONS".to_string());
160            }
161            map.insert(path.clone(), methods);
162        }
163    }
164    map
165}
166
167/// Try to match a concrete path (e.g., "/users/123") to a spec path template
168/// (e.g., "/users/{id}"). Handles base_path stripping.
169fn find_matching_spec_path(
170    check_path: &str,
171    spec_ops: &SpecOperationMap,
172    base_path: Option<&str>,
173) -> Option<String> {
174    // Try exact match first
175    if spec_ops.contains_key(check_path) {
176        return Some(check_path.to_string());
177    }
178
179    // Try with base_path prepended
180    if let Some(bp) = base_path {
181        let with_base = format!("{}{}", bp.trim_end_matches('/'), check_path);
182        if spec_ops.contains_key(&with_base) {
183            return Some(with_base);
184        }
185    }
186
187    // Try template matching (e.g., /users/123 matches /users/{id})
188    for spec_path in spec_ops.keys() {
189        if path_matches_template(check_path, spec_path)
190            || base_path
191                .map(|bp| {
192                    let with_base = format!("{}{}", bp.trim_end_matches('/'), check_path);
193                    path_matches_template(&with_base, spec_path)
194                })
195                .unwrap_or(false)
196        {
197            return Some(spec_path.clone());
198        }
199    }
200
201    None
202}
203
204/// Match a single templated path segment against a concrete one and, on
205/// success, return the bound `(param_name, value)` when the segment carries a
206/// `{param}` placeholder.
207///
208/// Handles a plain `{name}`, a literal segment (`v1`), AND — Round 54 (#79) —
209/// a single `{name}` wrapped in literals, i.e. Google's custom-verb form
210/// `{instance}:reportStatus` (also `prefix-{name}`). Multi-placeholder
211/// segments (`{a}-{b}`) fall back to plain equality. Returns:
212///   - `Some(None)` when the segment matches with no bound param (literal),
213///   - `Some(Some((name, value)))` when it matches and binds a param,
214///   - `None` when it does not match.
215fn match_path_segment<'a>(
216    template_seg: &'a str,
217    concrete_seg: &str,
218) -> Option<Option<(&'a str, String)>> {
219    let open = template_seg.find('{');
220    let close = template_seg.find('}');
221    match (open, close) {
222        (Some(o), Some(c)) if c > o && !template_seg[c + 1..].contains('{') => {
223            let prefix = &template_seg[..o];
224            let name = &template_seg[o + 1..c];
225            let suffix = &template_seg[c + 1..];
226            if concrete_seg.starts_with(prefix)
227                && concrete_seg.ends_with(suffix)
228                && concrete_seg.len() >= prefix.len() + suffix.len()
229            {
230                let value = &concrete_seg[prefix.len()..concrete_seg.len() - suffix.len()];
231                Some(Some((name, value.to_string())))
232            } else {
233                None
234            }
235        }
236        // No single clean placeholder — require literal equality.
237        _ => {
238            if template_seg == concrete_seg {
239                Some(None)
240            } else {
241                None
242            }
243        }
244    }
245}
246
247/// Check if a concrete path matches a path template with {param} segments
248fn path_matches_template(concrete: &str, template: &str) -> bool {
249    let concrete_parts: Vec<&str> = concrete.split('/').collect();
250    let template_parts: Vec<&str> = template.split('/').collect();
251
252    if concrete_parts.len() != template_parts.len() {
253        return false;
254    }
255
256    concrete_parts
257        .iter()
258        .zip(template_parts.iter())
259        .all(|(c, t)| match_path_segment(t, c).is_some())
260}
261
262/// Validate request body against the spec's requestBody schema
263#[allow(clippy::too_many_arguments)]
264fn validate_request_body(
265    check_name: &str,
266    method: &str,
267    path: &str,
268    body: Option<&str>,
269    operation: &openapiv3::Operation,
270    spec: &OpenAPI,
271    violations: &mut Vec<RequestViolation>,
272) {
273    let request_body_ref = match &operation.request_body {
274        Some(rb) => rb,
275        None => {
276            // Spec doesn't define a requestBody — body is optional
277            return;
278        }
279    };
280
281    // Resolve $ref if needed
282    let request_body = match request_body_ref {
283        ReferenceOr::Item(rb) => rb,
284        ReferenceOr::Reference { reference } => {
285            let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
286            match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
287                Some(ReferenceOr::Item(rb)) => rb,
288                _ => return,
289            }
290        }
291    };
292
293    // Check if body is required but missing
294    if request_body.required && body.is_none() {
295        violations.push(RequestViolation {
296            check_name: check_name.to_string(),
297            method: method.to_string(),
298            path: path.to_string(),
299            violation_type: "missing_required_body".to_string(),
300            message: "Spec requires a request body but none is provided in the check".to_string(),
301        });
302        return;
303    }
304
305    // If body is provided, validate against schema
306    if let Some(body_str) = body {
307        // Find JSON content type
308        let json_media = request_body.content.get("application/json").or_else(|| {
309            request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
310        });
311
312        if let Some(media) = json_media {
313            if let Some(schema_ref) = &media.schema {
314                // Resolve the immediate $ref (one level) to get the
315                // root schema, then hand both schema + spec to the
316                // ref-resolver helper so nested `$ref` strings (e.g.
317                // `#/components/schemas/Vcenter.VM.DiskCloneSpec`)
318                // resolve against the full document context.
319                //
320                // Round 18.3 — pre-fix this called
321                // `jsonschema::validator_for(&schema_json)` directly,
322                // which used the inner schema as the validator's
323                // document. Nested $refs to `#/components/schemas/X`
324                // then failed with "Pointer '...' does not exist"
325                // because the validator's document had no
326                // `components` key (Srikanth's vCenter run: 157
327                // violations).
328                let root_schema = match schema_ref {
329                    ReferenceOr::Item(s) => s.clone(),
330                    ReferenceOr::Reference { reference } => {
331                        let name =
332                            reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
333                        match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
334                            Some(ReferenceOr::Item(s)) => s.clone(),
335                            _ => return,
336                        }
337                    }
338                };
339
340                // Parse body as JSON and validate against schema
341                match serde_json::from_str::<serde_json::Value>(body_str) {
342                    Ok(body_value) => {
343                        match mockforge_openapi::schema_ref_resolver::build_validator(
344                            &root_schema,
345                            spec,
346                        ) {
347                            Ok(validator) => {
348                                let errors: Vec<_> = validator.iter_errors(&body_value).collect();
349                                for err in errors.iter().take(5) {
350                                    violations.push(RequestViolation {
351                                        check_name: check_name.to_string(),
352                                        method: method.to_string(),
353                                        path: path.to_string(),
354                                        violation_type: "body_schema_violation".to_string(),
355                                        message: format!(
356                                            "Request body schema violation at {}: {}",
357                                            err.instance_path, err
358                                        ),
359                                    });
360                                }
361                            }
362                            Err(_) => {
363                                // Schema itself is invalid — skip validation
364                            }
365                        }
366                    }
367                    Err(e) => {
368                        violations.push(RequestViolation {
369                            check_name: check_name.to_string(),
370                            method: method.to_string(),
371                            path: path.to_string(),
372                            violation_type: "body_not_json".to_string(),
373                            message: format!("Request body is not valid JSON: {}", e),
374                        });
375                    }
376                }
377            }
378        }
379    }
380}
381
382/// Validate required parameters from the spec
383#[allow(clippy::too_many_arguments)]
384fn validate_parameters(
385    check_name: &str,
386    method: &str,
387    path: &str,
388    check_path_no_query: &str,
389    check_headers: &HashMap<String, String>,
390    operation: &openapiv3::Operation,
391    path_item: &openapiv3::PathItem,
392    spec: &OpenAPI,
393    violations: &mut Vec<RequestViolation>,
394) {
395    // Collect all parameters (path-level + operation-level)
396    let mut all_params = Vec::new();
397    for p in &path_item.parameters {
398        if let Some(param) = resolve_parameter(p, spec) {
399            all_params.push(param);
400        }
401    }
402    for p in &operation.parameters {
403        if let Some(param) = resolve_parameter(p, spec) {
404            all_params.push(param);
405        }
406    }
407
408    for param in &all_params {
409        let param_data = match param {
410            openapiv3::Parameter::Query { parameter_data, .. } => {
411                if !parameter_data.required {
412                    continue;
413                }
414                // Check if query param is in the path's query string
415                let has_param = check_path_no_query != path
416                    && path.contains(&format!("{}=", parameter_data.name));
417                if !has_param {
418                    violations.push(RequestViolation {
419                        check_name: check_name.to_string(),
420                        method: method.to_string(),
421                        path: path.to_string(),
422                        violation_type: "missing_required_query_param".to_string(),
423                        message: format!(
424                            "Required query parameter '{}' is missing",
425                            parameter_data.name
426                        ),
427                    });
428                }
429                continue;
430            }
431            openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
432            openapiv3::Parameter::Path { parameter_data, .. } => {
433                // Path params are always required — but they're embedded in the URL
434                // so we can't easily validate them here (they're already resolved)
435                let _ = parameter_data;
436                continue;
437            }
438            openapiv3::Parameter::Cookie { .. } => continue,
439        };
440
441        if param_data.required {
442            let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(&param_data.name));
443            if !has_header {
444                violations.push(RequestViolation {
445                    check_name: check_name.to_string(),
446                    method: method.to_string(),
447                    path: path.to_string(),
448                    violation_type: "missing_required_header".to_string(),
449                    message: format!("Required header parameter '{}' is missing", param_data.name),
450                });
451            }
452        }
453    }
454}
455
456/// Resolve a parameter reference
457fn resolve_parameter<'a>(
458    param_ref: &'a ReferenceOr<openapiv3::Parameter>,
459    spec: &'a OpenAPI,
460) -> Option<&'a openapiv3::Parameter> {
461    match param_ref {
462        ReferenceOr::Item(p) => Some(p),
463        ReferenceOr::Reference { reference } => {
464            let name = reference.strip_prefix("#/components/parameters/")?;
465            match spec.components.as_ref()?.parameters.get(name)? {
466                ReferenceOr::Item(p) => Some(p),
467                _ => None,
468            }
469        }
470    }
471}
472
473/// Round 53 (#79) — percent-decode a query key/value or a path segment.
474///
475/// The spec declares parameters by their decoded name (`$.xgafv`), but the
476/// wire carries them encoded (`%24.xgafv`). Matching the raw wire key against
477/// the spec name silently skipped every parameter whose name needs escaping,
478/// which is why all of Srikanth's `owasp:*` probes (they all inject into
479/// `$.xgafv`) produced zero violations. Decoding the value as well keeps the
480/// reported message readable.
481///
482/// Falls back to the input unchanged when it isn't valid percent-encoded
483/// UTF-8, so a malformed probe can never panic or drop the parameter.
484fn pct_decode(s: &str) -> String {
485    urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
486}
487
488/// Round 53 (#79) — resolve a parameter's schema, following a single
489/// `#/components/schemas/...` reference. Mirrors the request-body resolution
490/// added in r52; without it a `$ref`'d parameter schema is silently skipped.
491fn resolve_param_schema<'a>(
492    schema_ref: &'a ReferenceOr<openapiv3::Schema>,
493    spec: &'a OpenAPI,
494) -> Option<&'a openapiv3::Schema> {
495    match schema_ref {
496        ReferenceOr::Item(s) => Some(s),
497        ReferenceOr::Reference { reference } => {
498            let name = reference.strip_prefix("#/components/schemas/")?;
499            match spec.components.as_ref()?.schemas.get(name)? {
500                ReferenceOr::Item(s) => Some(s),
501                _ => None,
502            }
503        }
504    }
505}
506
507/// Resolve a schema reference to a serde_json::Value for validation.
508/// Reserved for round 21.3 (response-body shape validation against the
509/// spec's response schema). Not yet wired into a call site.
510#[allow(dead_code)]
511fn resolve_schema_to_json(
512    schema_ref: &ReferenceOr<openapiv3::Schema>,
513    spec: &OpenAPI,
514) -> Option<serde_json::Value> {
515    let schema = match schema_ref {
516        ReferenceOr::Item(s) => s,
517        ReferenceOr::Reference { reference } => {
518            let name = reference.strip_prefix("#/components/schemas/")?;
519            match spec.components.as_ref()?.schemas.get(name)? {
520                ReferenceOr::Item(s) => s,
521                _ => return None,
522            }
523        }
524    };
525    serde_json::to_value(schema).ok()
526}
527
528/// Run request validation and write results to a file.
529/// Called from the conformance execution path.
530pub async fn run_request_validation(
531    spec_files: &[std::path::PathBuf],
532    custom_checks_file: Option<&Path>,
533    base_path: Option<&str>,
534    output_dir: &Path,
535) -> Result<usize> {
536    let custom_file = match custom_checks_file {
537        Some(f) => f,
538        None => return Ok(0),
539    };
540
541    if spec_files.is_empty() {
542        return Ok(0);
543    }
544
545    let parser = SpecParser::from_file(&spec_files[0]).await?;
546    let spec = parser.spec();
547
548    let violations = validate_custom_checks(spec, custom_file, base_path)?;
549
550    if !violations.is_empty() {
551        let path = output_dir.join("conformance-request-violations.json");
552        if let Ok(json) = serde_json::to_string_pretty(&violations) {
553            let _ = std::fs::write(&path, json);
554            tracing::info!(
555                "Found {} request validation violation(s), saved to {}",
556                violations.len(),
557                path.display()
558            );
559        }
560    }
561
562    Ok(violations.len())
563}
564
565/// Round 44 (#79) — validate each emitted request retrospectively
566/// against the OpenAPI spec, after the bench run completes. Reads
567/// `conformance-requests.json` (which `--export-requests` writes) and
568/// emits one [`RequestViolation`] entry per actual wire-level
569/// rule break (enum, type, required field, etc.), so a user can see
570/// the client's own view of what it sent that violated the contract
571/// without having to query the server's `/__mockforge/api/conformance/violations`.
572///
573/// Srikanth on 0.3.188: "Any reason why validate-requests in mockforge
574/// client are not catching all this query param or body params or path
575/// params violation issues and record in conformance-request-failure
576/// logs?" The existing `validate_custom_checks` only looks at the YAML
577/// shape at config time (missing required params, unknown path);
578/// auto-generated self-test probes ARE intentionally invalid but were
579/// never recorded client-side because they don't come from the YAML.
580/// This function complements the YAML-shape pass by checking each
581/// emitted request against the spec's actual rule set.
582///
583/// Appends to (not overwrites) `conformance-request-violations.json`
584/// when YAML-shape violations were already written above, so a single
585/// file holds both views.
586pub async fn validate_emitted_requests(
587    spec_files: &[std::path::PathBuf],
588    output_dir: &Path,
589) -> Result<usize> {
590    validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
591}
592
593/// Round 45 (#79) — same as `validate_emitted_requests` but accepts an
594/// explicit `base_path` (e.g. Srikanth's `--base-path /api` for the
595/// Apigee spec where every operation lives under `/api/v1/...` on the
596/// wire but `/v1/...` in the spec). Without it the emitted URL doesn't
597/// match the spec path and every request silently skips validation.
598///
599/// Also broadened in r45 to:
600/// - extract path params from the URL and validate their values
601///   against the spec's path-parameter schemas (enum / type)
602/// - parse the request body when content-type is JSON and walk it
603///   against the requestBody schema's `required: [...]` and enum
604///   constraints on top-level properties
605///
606/// Body and path-param coverage is INTENTIONALLY shallow (top-level
607/// `required` + `enum`/`type` on direct properties only) — the
608/// authoritative validator is the OpenAPI server's; this is the
609/// client-side cross-check that mirrors the server's view on the
610/// wire-level requests the bench actually sent.
611pub async fn validate_emitted_requests_with_base_path(
612    spec_files: &[std::path::PathBuf],
613    output_dir: &Path,
614    base_path: Option<&str>,
615) -> Result<usize> {
616    use serde_json::Value;
617
618    if spec_files.is_empty() {
619        return Ok(0);
620    }
621    let requests_path = output_dir.join("conformance-requests.json");
622    let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
623
624    // Round 49 (#79) — Srikanth on 0.3.193: self-test + --targets-file
625    // produced no violation logs because validate_emitted_requests
626    // only reads `conformance-requests.json` (the bench export
627    // shape), and self-test writes `conformance-self-test-
628    // requests.jsonl` (the CaseCapture shape). Now read whichever
629    // exists, converting the JSONL shape into the same `{check,
630    // method, url, request.body}` structure the validator below
631    // expects. If both exist, the bench export wins (a deliberate
632    // bench run shouldn't be overridden by stale self-test output).
633    let entries: Vec<Value> = if requests_path.exists() {
634        let bytes = match std::fs::read(&requests_path) {
635            Ok(b) => b,
636            Err(_) => return Ok(0),
637        };
638        match serde_json::from_slice(&bytes) {
639            Ok(v) => v,
640            Err(_) => return Ok(0),
641        }
642    } else if self_test_jsonl_path.exists() {
643        let bytes = match std::fs::read(&self_test_jsonl_path) {
644            Ok(b) => b,
645            Err(_) => return Ok(0),
646        };
647        let text = String::from_utf8_lossy(&bytes);
648        text.lines()
649            .filter(|l| !l.is_empty())
650            .filter_map(|l| serde_json::from_str::<Value>(l).ok())
651            .map(|case| {
652                let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
653                let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
654                let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
655                let body = case.get("request_body").cloned().unwrap_or(Value::Null);
656                let mut req = serde_json::Map::new();
657                req.insert("method".into(), Value::String(method));
658                req.insert("url".into(), Value::String(url));
659                req.insert(
660                    "body".into(),
661                    match body {
662                        Value::String(s) => Value::String(s),
663                        Value::Null => Value::String(String::new()),
664                        other => other,
665                    },
666                );
667                let mut out = serde_json::Map::new();
668                out.insert("check".into(), Value::String(label));
669                out.insert("request".into(), Value::Object(req));
670                Value::Object(out)
671            })
672            .collect()
673    } else {
674        return Ok(0);
675    };
676    if entries.is_empty() {
677        return Ok(0);
678    }
679
680    let parser = SpecParser::from_file(&spec_files[0]).await?;
681    let spec = parser.spec();
682    let spec_ops = build_spec_operation_map(spec);
683
684    let mut emitted_violations: Vec<RequestViolation> = Vec::new();
685
686    for entry in &entries {
687        let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
688        let req = match entry.get("request") {
689            Some(r) => r,
690            None => continue,
691        };
692        let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
693        let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
694        if method.is_empty() || url.is_empty() {
695            continue;
696        }
697        let (path_only, query_string) = match url.find('?') {
698            Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
699            None => (url.clone(), String::new()),
700        };
701        // Trim scheme + host from path so we match spec paths cleanly.
702        // "http://host:port/api/x" → "/api/x".
703        let path_only = if let Some(stripped) = path_only.split_once("://") {
704            match stripped.1.find('/') {
705                Some(i) => stripped.1[i..].to_string(),
706                None => "/".to_string(),
707            }
708        } else {
709            path_only
710        };
711
712        // Round 45 — strip base_path BEFORE matching so an Apigee-style
713        // `/api/v1/organizations` on the wire matches `/v1/organizations`
714        // in the spec when `--base-path /api` was passed.
715        let lookup_path = if let Some(bp) = base_path {
716            let bp = bp.trim_end_matches('/');
717            if !bp.is_empty() && path_only.starts_with(bp) {
718                let stripped = &path_only[bp.len()..];
719                if stripped.is_empty() {
720                    "/".to_string()
721                } else {
722                    stripped.to_string()
723                }
724            } else {
725                path_only.clone()
726            }
727        } else {
728            path_only.clone()
729        };
730
731        let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
732            Some(p) => p,
733            None => continue,
734        };
735        let path_item = match spec.paths.paths.get(&spec_path) {
736            Some(ReferenceOr::Item(item)) => item,
737            _ => continue,
738        };
739        let operation = match method.as_str() {
740            "GET" => path_item.get.as_ref(),
741            "POST" => path_item.post.as_ref(),
742            "PUT" => path_item.put.as_ref(),
743            "DELETE" => path_item.delete.as_ref(),
744            "PATCH" => path_item.patch.as_ref(),
745            "HEAD" => path_item.head.as_ref(),
746            "OPTIONS" => path_item.options.as_ref(),
747            _ => None,
748        };
749        let Some(operation) = operation else { continue };
750
751        // Inspect query parameters declared on this operation; for each
752        // sent query field, check it against the parameter's schema enum
753        // and type. This is what catches Srikanth's `?$.xgafv=test-value`
754        // case where the value isn't `"1"` or `"2"`.
755        //
756        // Round 53 (#79) — percent-decode BOTH the key and the value. The
757        // spec declares the parameter as `$.xgafv`, but it reaches the wire
758        // as `%24.xgafv`, so matching the raw key against the spec name
759        // missed every time and silently skipped the parameter. That hid all
760        // 7602 of Srikanth's `owasp:*` probes, which all inject into
761        // `$.xgafv`. Decoding the value too keeps the violation message
762        // readable (`' OR '1'='1` rather than `%27%20OR%20%271%27%3D%271`).
763        let sent_query: HashMap<String, String> = query_string
764            .split('&')
765            .filter_map(|kv| {
766                let mut it = kv.splitn(2, '=');
767                let k = pct_decode(it.next()?);
768                let v = pct_decode(it.next().unwrap_or(""));
769                if k.is_empty() {
770                    None
771                } else {
772                    Some((k, v))
773                }
774            })
775            .collect();
776
777        // Round 45 — bind path parameters by zipping the concrete URL
778        // path against the spec's template path. `/v1/{name}` ←
779        // `/v1/projects/abc` produces `{ "name": "projects/abc" }`.
780        // Used below to value-check each path-param against its
781        // declared schema (enum / type).
782        let path_params: HashMap<String, String> = {
783            let mut out = HashMap::new();
784            let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
785            let template_parts: Vec<&str> = spec_path.split('/').collect();
786            if concrete_parts.len() == template_parts.len() {
787                for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
788                    // Round 54 — reuse the segment matcher so custom-verb path
789                    // params (`{instance}:reportStatus`) bind too, not just bare
790                    // `{name}` segments. Round 53 — path values arrive encoded.
791                    if let Some(Some((name, value))) = match_path_segment(t, c) {
792                        out.insert(name.to_string(), pct_decode(&value));
793                    }
794                }
795            }
796            out
797        };
798
799        let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
800        for p in &path_item.parameters {
801            if let Some(param) = resolve_parameter(p, spec) {
802                all_params.push(param);
803            }
804        }
805        for p in &operation.parameters {
806            if let Some(param) = resolve_parameter(p, spec) {
807                all_params.push(param);
808            }
809        }
810
811        for param in &all_params {
812            let (loc_str, name, schema_ref) = match param {
813                openapiv3::Parameter::Query { parameter_data, .. } => {
814                    let openapiv3::ParameterSchemaOrContent::Schema(sref) = &parameter_data.format
815                    else {
816                        continue;
817                    };
818                    let Some(v) = sent_query.get(&parameter_data.name) else {
819                        // Round 53 (#79) — a REQUIRED query param that never
820                        // reached the wire is itself a spec violation. The
821                        // loop previously only inspected params that were
822                        // sent, so `parameters:missing-query` probes (which
823                        // drop a required param on purpose) produced nothing.
824                        if parameter_data.required {
825                            emitted_violations.push(RequestViolation {
826                                check_name: check.clone(),
827                                method: method.clone(),
828                                path: url.clone(),
829                                violation_type: "query_missing_required".to_string(),
830                                message: format!(
831                                    "query.{}: required parameter missing",
832                                    parameter_data.name
833                                ),
834                            });
835                        }
836                        continue;
837                    };
838                    ("query", &parameter_data.name, (sref, v.clone()))
839                }
840                openapiv3::Parameter::Path { parameter_data, .. } => {
841                    let openapiv3::ParameterSchemaOrContent::Schema(sref) = &parameter_data.format
842                    else {
843                        continue;
844                    };
845                    let Some(v) = path_params.get(&parameter_data.name) else {
846                        continue;
847                    };
848                    ("path", &parameter_data.name, (sref, v.clone()))
849                }
850                _ => continue,
851            };
852            let (schema_ref, value) = schema_ref;
853            // Round 53 — resolve `$ref` parameter schemas too; the same
854            // `as_item()` blind spot that hid `$ref` request bodies in r52
855            // applies to parameters whose schema is a component reference.
856            let Some(schema) = resolve_param_schema(schema_ref, spec) else {
857                continue;
858            };
859            if let Some(msg) = check_value_against_schema(&value, schema) {
860                emitted_violations.push(RequestViolation {
861                    check_name: check.clone(),
862                    method: method.clone(),
863                    path: url.clone(),
864                    violation_type: format!("{}_value_mismatch", loc_str),
865                    message: format!("{}.{}: {}", loc_str, name, msg),
866                });
867            }
868        }
869
870        // Round 45 — request-body cross-check. Only kicks in when the
871        // sent body parses as JSON and the operation declares a JSON
872        // requestBody schema.
873        //
874        // Round 52 (#79) — Srikanth on 0.3.198: a self-test +
875        // `--targets-file` run reported "2700 request-body caught" but
876        // the by-request / by-probe violation files came out EMPTY. The
877        // old shallow check here only fired when the requestBody media
878        // schema was an inline `ReferenceOr::Item`; the Apigee spec (and
879        // most real specs) declares
880        // `schema.$ref = #/components/schemas/GoogleCloudApigeeV1Organization`,
881        // so `schema_ref.as_item()` returned `None` and every body probe
882        // was silently skipped. It also only type-checked STRING values,
883        // so a `{"analyticsRegion":12345}` (number-where-string) probe
884        // never surfaced even when the schema resolved. We now resolve
885        // the requestBody + schema `$ref`s and reuse the same full JSON
886        // Schema validator `validate_custom_checks` uses (round 18.3's
887        // `build_validator`), so nested `$ref`s, root-type mismatches,
888        // and non-string type mismatches are all caught.
889        let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
890        if !body_str.is_empty() {
891            if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
892                validate_emitted_body(
893                    &check,
894                    &method,
895                    &url,
896                    &body_json,
897                    operation,
898                    spec,
899                    &mut emitted_violations,
900                );
901            }
902        }
903    }
904
905    // Merge with any pre-existing custom-YAML violations on disk.
906    let dst = output_dir.join("conformance-request-violations.json");
907    let mut all: Vec<Value> = if dst.exists() {
908        match std::fs::read(&dst) {
909            Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
910            Err(_) => Vec::new(),
911        }
912    } else {
913        Vec::new()
914    };
915    for v in &emitted_violations {
916        if let Ok(val) = serde_json::to_value(v) {
917            all.push(val);
918        }
919    }
920    // Round 50 (#79) — dedup byte-identical violations. A multi-iteration
921    // self-test captures one probe per iteration, so a 22x duration run
922    // produced 22 copies of every violation in the flat file (and, before
923    // the grouping fixes below, 22 copies inside each grouped row). Keep
924    // the first occurrence of each (check_name, method, path,
925    // violation_type, message) tuple; re-runs that merged the on-disk file
926    // are collapsed too. Preserves first-seen order.
927    {
928        let mut seen: std::collections::HashSet<(String, String, String, String, String)> =
929            std::collections::HashSet::new();
930        all.retain(|v| {
931            let f = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
932            seen.insert((
933                f("check_name"),
934                f("method"),
935                f("path"),
936                f("violation_type"),
937                f("message"),
938            ))
939        });
940    }
941    if !all.is_empty() {
942        if let Ok(json) = serde_json::to_string_pretty(&all) {
943            let _ = std::fs::write(&dst, json);
944            tracing::info!(
945                "validate-requests: wrote {} entries to {} ({} from emitted requests)",
946                all.len(),
947                dst.display(),
948                emitted_violations.len()
949            );
950        }
951    }
952
953    // Round 46 (#79) — Srikanth on 0.3.190: "I see three different
954    // messages, is this message for 3 different requests or for 1
955    // request. if it is 1 request can we have 1 line item mentioning
956    // violation 1 = message1, violation2 = message2 etc". Emit a
957    // sibling file grouped by (check_name, method, path) so each
958    // wire-level request shows up as a single row carrying every
959    // violation it raised. The per-violation file stays as-is for
960    // tooling that wants the flat shape.
961    let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
962    let grouped_value = group_violations_by_request(&all);
963    if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
964        let _ = std::fs::write(&grouped_dst, json);
965    }
966
967    // Round 48 (#79) — Srikanth on 0.3.192: "Can I assume all this
968    // checks has some violation either in the incoming request or
969    // outgoing response if yes then how can I see all this violation
970    // individually? Do we have any other Logs pointing each of those
971    // so that I can fix in one go?" New per-probe drill-down file
972    // emits one row per (check_name, method, path) carrying its full
973    // flat violation list. Lets the user see EXACTLY what each probe
974    // pattern (body:json, schema:string, constraint:enum, etc.)
975    // surfaced rather than just the deduped union the
976    // by-request file shows.
977    let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
978    let drill_value = group_violations_by_probe(&all);
979    if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
980        let _ = std::fs::write(&drill_dst, json);
981    }
982    Ok(emitted_violations.len())
983}
984
985/// Round 48 (#79) — emit one entry per (check_name, method, path)
986/// with its full violation list. Unlike `group_violations_by_request`,
987/// this preserves the per-probe view so the user can see WHICH spec-
988/// probing pattern (body:json / schema:string / constraint:enum /
989/// method:POST / etc.) surfaced WHICH violation. Sorted by check_name
990/// within the same (method, path) so probes group together visually.
991fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
992    use serde_json::{Map, Value};
993
994    let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
995    let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
996        std::collections::HashMap::new();
997
998    // Round 50 (#79) — Srikanth on 0.3.194: "I see same violation is
999    // getting printed in logs for 22 times" on a multi-iteration run.
1000    // The self-test capture holds one probe per iteration, so a 22x
1001    // duration run feeds 22 byte-identical violations per probe into
1002    // this flat list and we used to append all 22. Dedup identical
1003    // (violation_type, message) pairs WITHIN a probe so each unique
1004    // violation shows exactly once regardless of iteration count.
1005    let mut seen_in_probe: std::collections::HashSet<(String, String, String, String)> =
1006        std::collections::HashSet::new();
1007    for v in flat {
1008        let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1009        let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1010        let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1011        let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1012        let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1013        let key = (check.clone(), method.clone(), path.clone());
1014        if !by_probe.contains_key(&key) {
1015            by_probe_order.push(key.clone());
1016        }
1017        if seen_in_probe.insert((check, method, path, format!("{vt}\u{0}{msg}"))) {
1018            by_probe.entry(key).or_default().push((vt, msg));
1019        }
1020    }
1021
1022    // Sort within same (method, path) by check_name for visual grouping.
1023    by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
1024
1025    let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
1026    for key in &by_probe_order {
1027        let (check, method, path) = key;
1028        let entries = by_probe.get(key).cloned().unwrap_or_default();
1029        let mut row = Map::new();
1030        row.insert("check_name".into(), Value::String(check.clone()));
1031        row.insert("method".into(), Value::String(method.clone()));
1032        row.insert("path".into(), Value::String(path.clone()));
1033        row.insert(
1034            "violation_count".into(),
1035            Value::Number(serde_json::Number::from(entries.len())),
1036        );
1037        for (i, (vt, msg)) in entries.iter().enumerate() {
1038            let mut entry = Map::new();
1039            entry.insert("violation_type".into(), Value::String(vt.clone()));
1040            entry.insert("message".into(), Value::String(msg.clone()));
1041            row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1042        }
1043        rows.push(Value::Object(row));
1044    }
1045    Value::Array(rows)
1046}
1047
1048/// Round 46–50 (#79) — collapse the flat list of
1049/// [`RequestViolation`]-shaped JSON values into exactly ONE entry per
1050/// `(method, path)`.
1051///
1052/// History: Round 46 keyed on `(check_name, method, path)` (too many
1053/// duplicate rows). Round 47 collapsed by `(method, path)` AND the
1054/// violation set, listing contributing checks in a `checks: [...]`
1055/// array. But that re-split a single URL whenever two probe families
1056/// produced DIFFERENT violation sets for it — Srikanth on 0.3.194:
1057/// `owasp:ldap-injection` (query violations) landed in a different
1058/// by-request row than the `request-body:*` checks for the very same
1059/// URL, so his triage flow ("find the URL with the most violations
1060/// here, then drill into by-probe") missed half the picture.
1061///
1062/// Round 50 makes this file the authoritative per-URL overview: one row
1063/// per `(method, path)` carrying the DEDUPED UNION of every violation
1064/// and every contributing `check_name`. The per-probe attribution
1065/// ("which check surfaced which violation") lives in the sibling
1066/// `conformance-request-violations-by-probe.json`. First-seen order is
1067/// preserved for both checks and violations so the output is stable.
1068fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
1069    use serde_json::{Map, Value};
1070
1071    let mut order: Vec<(String, String)> = Vec::new();
1072    let mut checks_by_key: std::collections::HashMap<(String, String), Vec<String>> =
1073        std::collections::HashMap::new();
1074    let mut viols_by_key: std::collections::HashMap<(String, String), Vec<(String, String)>> =
1075        std::collections::HashMap::new();
1076    // Per-(method,path) dedup sets so a check fired across 22 iterations,
1077    // or the same (vt,msg) surfaced by several checks, is counted once.
1078    let mut seen_check: std::collections::HashSet<(String, String, String)> =
1079        std::collections::HashSet::new();
1080    let mut seen_viol: std::collections::HashSet<(String, String, String)> =
1081        std::collections::HashSet::new();
1082
1083    for v in flat {
1084        let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1085        let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1086        let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1087        let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1088        let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1089        let key = (method.clone(), path.clone());
1090        if !checks_by_key.contains_key(&key) && !viols_by_key.contains_key(&key) {
1091            order.push(key.clone());
1092        }
1093        if !check.is_empty() && seen_check.insert((method.clone(), path.clone(), check.clone())) {
1094            checks_by_key.entry(key.clone()).or_default().push(check);
1095        }
1096        if seen_viol.insert((method.clone(), path.clone(), format!("{vt}\u{0}{msg}"))) {
1097            viols_by_key.entry(key).or_default().push((vt, msg));
1098        }
1099    }
1100
1101    let mut rows: Vec<Value> = Vec::with_capacity(order.len());
1102    for key in &order {
1103        let (method, path) = key;
1104        let checks = checks_by_key.get(key).cloned().unwrap_or_default();
1105        let viols = viols_by_key.get(key).cloned().unwrap_or_default();
1106        let mut row = Map::new();
1107        row.insert(
1108            "checks".into(),
1109            Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
1110        );
1111        // Round 48 (#79) — keep a single representative `check_name`
1112        // pointing at the check whose family matches the FIRST violation,
1113        // so the headline check isn't misleading. The full set is in
1114        // `checks[]`; per-violation attribution is in the by-probe file.
1115        let dominant_prefix: &str = viols
1116            .first()
1117            .map(|(vt, _)| {
1118                if vt.starts_with("query_") {
1119                    "param:query"
1120                } else if vt.starts_with("body_") {
1121                    "body:"
1122                } else if vt.starts_with("path_") {
1123                    "param:path"
1124                } else if vt.starts_with("header_") {
1125                    "param:header"
1126                } else {
1127                    ""
1128                }
1129            })
1130            .unwrap_or("");
1131        let best_check = if !dominant_prefix.is_empty() {
1132            checks
1133                .iter()
1134                .find(|c| c.starts_with(dominant_prefix))
1135                .cloned()
1136                .or_else(|| checks.first().cloned())
1137                .unwrap_or_default()
1138        } else {
1139            checks.first().cloned().unwrap_or_default()
1140        };
1141        row.insert("check_name".into(), Value::String(best_check));
1142        row.insert("method".into(), Value::String(method.clone()));
1143        row.insert("path".into(), Value::String(path.clone()));
1144        row.insert("violation_count".into(), Value::Number(serde_json::Number::from(viols.len())));
1145        for (i, (vt, msg)) in viols.iter().enumerate() {
1146            let mut entry = Map::new();
1147            entry.insert("violation_type".into(), Value::String(vt.clone()));
1148            entry.insert("message".into(), Value::String(msg.clone()));
1149            row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1150        }
1151        rows.push(Value::Object(row));
1152    }
1153    Value::Array(rows)
1154}
1155
1156/// Round 52 (#79) — validate an emitted request body against the
1157/// operation's requestBody schema, resolving `$ref` at both the
1158/// requestBody and schema level and delegating to the same full JSON
1159/// Schema validator (`build_validator`) the custom-checks path uses.
1160///
1161/// This replaced a shallow, `$ref`-unaware check that only fired for
1162/// inline schemas and only type-checked string values — which is why a
1163/// self-test run against the Apigee spec (whose request bodies are all
1164/// `$ref`s to component schemas) produced empty violation logs even
1165/// though the summary reported thousands of caught request-body
1166/// negatives. We cap at 5 errors per body so a deeply-broken probe
1167/// can't flood the log; the by-probe file still gets one row per probe.
1168fn validate_emitted_body(
1169    check: &str,
1170    method: &str,
1171    url: &str,
1172    body: &serde_json::Value,
1173    operation: &openapiv3::Operation,
1174    spec: &OpenAPI,
1175    violations: &mut Vec<RequestViolation>,
1176) {
1177    // Resolve the requestBody (may itself be a $ref into components).
1178    let Some(request_body_ref) = &operation.request_body else {
1179        return;
1180    };
1181    let request_body = match request_body_ref {
1182        ReferenceOr::Item(rb) => rb,
1183        ReferenceOr::Reference { reference } => {
1184            let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
1185            match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
1186                Some(ReferenceOr::Item(rb)) => rb,
1187                _ => return,
1188            }
1189        }
1190    };
1191
1192    // Only cross-check JSON bodies — other media types (multipart,
1193    // urlencoded) are handled elsewhere / by the server validator.
1194    let json_media = request_body
1195        .content
1196        .get("application/json")
1197        .or_else(|| request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v));
1198    let Some(media) = json_media else {
1199        return;
1200    };
1201    let Some(schema_ref) = &media.schema else {
1202        return;
1203    };
1204
1205    // Resolve the immediate schema $ref (one level) to the root schema,
1206    // then hand it to the resolver so nested $refs resolve against the
1207    // full document (round 18.3's fix for vCenter's nested components).
1208    let root_schema = match schema_ref {
1209        ReferenceOr::Item(s) => s.clone(),
1210        ReferenceOr::Reference { reference } => {
1211            let name = reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
1212            match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
1213                Some(ReferenceOr::Item(s)) => s.clone(),
1214                _ => return,
1215            }
1216        }
1217    };
1218
1219    let Ok(validator) = mockforge_openapi::schema_ref_resolver::build_validator(&root_schema, spec)
1220    else {
1221        // Schema itself is unbuildable — skip rather than false-positive.
1222        return;
1223    };
1224    for err in validator.iter_errors(body).take(5) {
1225        let loc = err.instance_path.to_string();
1226        let loc = if loc.is_empty() { "$".to_string() } else { loc };
1227        violations.push(RequestViolation {
1228            check_name: check.to_string(),
1229            method: method.to_string(),
1230            path: url.to_string(),
1231            violation_type: "body_schema_violation".to_string(),
1232            message: format!("body{}: {}", loc, err),
1233        });
1234    }
1235}
1236
1237/// Round 44 (#79) — minimal value-vs-schema check for the retroactive
1238/// emitted-request validator. Returns a human-readable error message
1239/// when the value doesn't satisfy the schema, or `None` when it does.
1240/// Only handles the rules Srikanth's Apigee spec uses (enum, type:
1241/// integer, type: boolean); falls through silently for any other
1242/// rule rather than producing a false positive.
1243fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1244    use openapiv3::{SchemaKind, Type};
1245
1246    let SchemaKind::Type(t) = &schema.schema_kind else {
1247        return None;
1248    };
1249    match t {
1250        Type::String(s) => {
1251            if !s.enumeration.is_empty() {
1252                let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1253                if !allowed.iter().any(|a| a == value) {
1254                    let quoted: Vec<String> =
1255                        allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1256                    return Some(format!(
1257                        "value \"{}\" is not one of {}",
1258                        value,
1259                        quoted.join(" or ")
1260                    ));
1261                }
1262            }
1263            // Round 54 (#79) — string length + pattern constraints. Without
1264            // these a `bad-path-param` / bad-query probe against a param that
1265            // declares `pattern` / `minLength` / `maxLength` produced no
1266            // violation even though the value clearly breaks the contract.
1267            let len = value.chars().count();
1268            if let Some(min) = s.min_length {
1269                if len < min {
1270                    return Some(format!("value \"{value}\" is shorter than minLength {min}"));
1271                }
1272            }
1273            if let Some(max) = s.max_length {
1274                if len > max {
1275                    return Some(format!("value \"{value}\" is longer than maxLength {max}"));
1276                }
1277            }
1278            if let Some(pat) = &s.pattern {
1279                // Best-effort: an uncompilable pattern is skipped rather than
1280                // reported as a false positive.
1281                if let Ok(re) = regex::Regex::new(pat) {
1282                    if !re.is_match(value) {
1283                        return Some(format!("value \"{value}\" does not match pattern /{pat}/"));
1284                    }
1285                }
1286            }
1287            None
1288        }
1289        Type::Integer(_) => {
1290            if value.parse::<i64>().is_err() {
1291                Some(format!("value \"{}\" is not of type \"integer\"", value))
1292            } else {
1293                None
1294            }
1295        }
1296        Type::Number(_) => {
1297            if value.parse::<f64>().is_err() {
1298                Some(format!("value \"{}\" is not of type \"number\"", value))
1299            } else {
1300                None
1301            }
1302        }
1303        Type::Boolean(_) => match value {
1304            "true" | "false" => None,
1305            _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1306        },
1307        _ => None,
1308    }
1309}
1310
1311#[cfg(test)]
1312mod grouping_tests {
1313    use super::{group_violations_by_probe, group_violations_by_request};
1314    use serde_json::json;
1315
1316    /// Build a flat violation value the way `validate_emitted_requests` does.
1317    fn viol(check: &str, method: &str, path: &str, vt: &str, msg: &str) -> serde_json::Value {
1318        json!({
1319            "check_name": check,
1320            "method": method,
1321            "path": path,
1322            "violation_type": vt,
1323            "message": msg,
1324        })
1325    }
1326
1327    /// Round 50 (#79) — reproduces Srikanth's 0.3.194 report: a single URL
1328    /// whose query violations come from `owasp:ldap-injection` while its
1329    /// body violations come from `request-body:*` checks must collapse into
1330    /// ONE by-request row that lists BOTH check families and the UNION of
1331    /// every violation. Previously these split into two separate rows, so
1332    /// the owasp check was invisible from the body row he was reading.
1333    #[test]
1334    fn by_request_unions_all_checks_for_a_url() {
1335        let path = "https://host/v1/organizations?alt=test-value&prettyPrint=test-value";
1336        let flat = vec![
1337            viol(
1338                "request-body:type-mismatch:billingType",
1339                "POST",
1340                path,
1341                "body_type_mismatch",
1342                "body.billingType: expected string",
1343            ),
1344            viol(
1345                "owasp:ldap-injection",
1346                "POST",
1347                path,
1348                "query_value_mismatch",
1349                "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1350            ),
1351            viol(
1352                "owasp:ldap-injection",
1353                "POST",
1354                path,
1355                "query_value_mismatch",
1356                "query.prettyPrint: value \"test-value\" is not of type \"boolean\"",
1357            ),
1358        ];
1359
1360        let out = group_violations_by_request(&flat);
1361        let rows = out.as_array().expect("array");
1362        // Exactly one row for the URL — no fragmentation.
1363        assert_eq!(rows.len(), 1, "expected a single by-request row per URL");
1364        let row = &rows[0];
1365        assert_eq!(row["violation_count"], 3);
1366        let checks: Vec<&str> =
1367            row["checks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
1368        assert!(checks.contains(&"owasp:ldap-injection"), "owasp check must appear: {checks:?}");
1369        assert!(
1370            checks.iter().any(|c| c.starts_with("request-body:")),
1371            "body check must appear: {checks:?}"
1372        );
1373    }
1374
1375    /// Round 50 (#79) — "I see same violation is getting printed in logs for
1376    /// 22 times." A multi-iteration run feeds N identical violations per
1377    /// probe; the by-probe drill-down must show each unique violation once.
1378    #[test]
1379    fn by_probe_dedups_repeated_iterations() {
1380        let path = "https://host/v1/organizations?alt=test-value";
1381        let mut flat = Vec::new();
1382        for _ in 0..22 {
1383            flat.push(viol(
1384                "owasp:ldap-injection",
1385                "POST",
1386                path,
1387                "query_value_mismatch",
1388                "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1389            ));
1390        }
1391
1392        let out = group_violations_by_probe(&flat);
1393        let rows = out.as_array().expect("array");
1394        assert_eq!(rows.len(), 1, "one probe row");
1395        assert_eq!(rows[0]["violation_count"], 1, "22 identical iterations collapse to 1");
1396        assert!(rows[0].get("violation_1").is_some());
1397        assert!(rows[0].get("violation_2").is_none(), "no duplicate violation_2");
1398    }
1399
1400    /// The by-request union must also collapse the 22x duplicates, not just
1401    /// dedup across checks.
1402    #[test]
1403    fn by_request_dedups_repeated_iterations() {
1404        let path = "https://host/v1/widgets";
1405        let mut flat = Vec::new();
1406        for _ in 0..22 {
1407            flat.push(viol(
1408                "request-body:type-mismatch:name",
1409                "POST",
1410                path,
1411                "body_type_mismatch",
1412                "body.name: expected string",
1413            ));
1414        }
1415        let out = group_violations_by_request(&flat);
1416        let rows = out.as_array().unwrap();
1417        assert_eq!(rows.len(), 1);
1418        assert_eq!(rows[0]["violation_count"], 1, "duplicate iterations collapse");
1419        let checks = rows[0]["checks"].as_array().unwrap();
1420        assert_eq!(checks.len(), 1, "the same check listed once");
1421    }
1422
1423    /// Distinct URLs stay distinct.
1424    #[test]
1425    fn by_request_keeps_distinct_urls_separate() {
1426        let flat = vec![
1427            viol("c1", "POST", "https://host/a", "body_type_mismatch", "a"),
1428            viol("c2", "GET", "https://host/b", "query_value_mismatch", "b"),
1429        ];
1430        let out = group_violations_by_request(&flat);
1431        assert_eq!(out.as_array().unwrap().len(), 2);
1432    }
1433}
1434
1435#[cfg(test)]
1436mod emitted_body_tests {
1437    use super::validate_emitted_requests_with_base_path;
1438    use std::io::Write;
1439
1440    /// Round 52 (#79) — Srikanth on 0.3.198: a `--conformance-self-test
1441    /// --targets-file` run reported "2700 request-body caught" in the
1442    /// summary but wrote EMPTY `conformance-request-violations-by-request.json`
1443    /// and `-by-probe.json`. Root cause: the emitted-request validator's
1444    /// body check (`check_body_against_schema`) only fired when the
1445    /// requestBody media schema was an inline `ReferenceOr::Item`. The
1446    /// Apigee spec (like most real specs) declares
1447    /// `requestBody.content.application/json.schema.$ref =
1448    /// #/components/schemas/GoogleCloudApigeeV1Organization`, so
1449    /// `schema_ref.as_item()` returned `None` and every body probe was
1450    /// skipped. It also only type-checked STRING property values, so a
1451    /// `{"analyticsRegion":12345}` (number where string expected) probe
1452    /// produced no violation even when the schema resolved.
1453    ///
1454    /// This reproduces the multi-target self-test shape: a JSONL of
1455    /// captured probes, a spec whose requestBody is a `$ref`, and the
1456    /// exact negative labels the self-test generator emits.
1457    #[tokio::test]
1458    async fn emitted_requests_validate_ref_bodied_negatives() {
1459        let dir = tempfile::tempdir().expect("tempdir");
1460
1461        // Spec: /v1/organizations POST, requestBody is a $ref to a
1462        // component schema (the real-world shape). No `required` fields
1463        // so the positive `{}` probe stays clean (no false positive).
1464        let spec_json = serde_json::json!({
1465            "openapi": "3.0.0",
1466            "info": { "title": "apigee-min", "version": "1.0.0" },
1467            "paths": {
1468                "/v1/organizations": {
1469                    "post": {
1470                        "requestBody": {
1471                            "content": {
1472                                "application/json": {
1473                                    "schema": { "$ref": "#/components/schemas/Organization" }
1474                                }
1475                            }
1476                        },
1477                        "responses": { "200": { "description": "ok" } }
1478                    }
1479                }
1480            },
1481            "components": {
1482                "schemas": {
1483                    "Organization": {
1484                        "type": "object",
1485                        "properties": {
1486                            "analyticsRegion": { "type": "string" },
1487                            "displayName": { "type": "string" }
1488                        }
1489                    }
1490                }
1491            }
1492        });
1493        let spec_path = dir.path().join("apigee-min.json");
1494        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1495
1496        // JSONL of captured probes, mirroring the self-test capture shape
1497        // (label / method / url / request_body). One positive, two
1498        // negatives (a type-mismatch on a $ref'd property, and a
1499        // wrong-root-type body).
1500        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1501        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1502        let base = "https://172.22.232.2:443/v1/organizations?alt=json";
1503        for line in [
1504            serde_json::json!({
1505                "label": "positive", "method": "POST", "url": base, "request_body": "{}"
1506            }),
1507            serde_json::json!({
1508                "label": "request-body:type-mismatch:analyticsRegion",
1509                "method": "POST", "url": base,
1510                "request_body": "{\"analyticsRegion\":12345}"
1511            }),
1512            serde_json::json!({
1513                "label": "request-body:wrong-type",
1514                "method": "POST", "url": base, "request_body": "[]"
1515            }),
1516        ] {
1517            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1518        }
1519        drop(f);
1520
1521        let n = validate_emitted_requests_with_base_path(
1522            std::slice::from_ref(&spec_path),
1523            dir.path(),
1524            None,
1525        )
1526        .await
1527        .expect("validation runs");
1528
1529        assert!(n >= 2, "expected the two request-body negatives to be flagged, got {n}");
1530
1531        // The grouped files the user actually reads must be non-empty.
1532        let by_request = std::fs::read_to_string(
1533            dir.path().join("conformance-request-violations-by-request.json"),
1534        )
1535        .unwrap();
1536        let by_request: serde_json::Value = serde_json::from_str(&by_request).unwrap();
1537        assert!(
1538            !by_request.as_array().unwrap().is_empty(),
1539            "by-request file must not be empty for a spec with $ref request bodies"
1540        );
1541
1542        let by_probe = std::fs::read_to_string(
1543            dir.path().join("conformance-request-violations-by-probe.json"),
1544        )
1545        .unwrap();
1546        let by_probe: serde_json::Value = serde_json::from_str(&by_probe).unwrap();
1547        assert!(!by_probe.as_array().unwrap().is_empty(), "by-probe file must not be empty");
1548
1549        // The type-mismatch probe must surface as a violation naming the field.
1550        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1551            .unwrap();
1552        assert!(
1553            flat.contains("analyticsRegion"),
1554            "the number-where-string probe must be reported: {flat}"
1555        );
1556    }
1557
1558    /// Round 53 (#79) — Srikanth on 0.3.199: body violations now populate,
1559    /// but the logs contain ONLY `request-body:*` rows. His console reported
1560    /// 7602 missed `owasp` and 3248 missed `parameters` negatives, yet not a
1561    /// single `owasp:*` row or query/path violation appeared.
1562    ///
1563    /// Two distinct causes, both reproduced here against the real wire shape:
1564    ///
1565    /// 1. Every owasp probe injects into `$.xgafv`, which reaches the wire
1566    ///    percent-encoded as `%24.xgafv`. The validator built `sent_query`
1567    ///    from the RAW query string, then looked the parameter up by the
1568    ///    spec's DECODED name (`$.xgafv`), so the lookup missed and all 7602
1569    ///    probes were silently skipped. (Round 45's test used a plain `alt`
1570    ///    param, which needs no encoding, so this stayed latent.)
1571    ///
1572    /// 2. `parameters:missing-query` DROPS a required query param. The loop
1573    ///    only inspected params that were actually sent, so a missing
1574    ///    required param could never be reported.
1575    #[tokio::test]
1576    async fn emitted_requests_flag_encoded_query_and_missing_required() {
1577        let dir = tempfile::tempdir().expect("tempdir");
1578
1579        // Spec mirrors the Apigee shape: a param whose name needs percent
1580        // encoding (`$.xgafv`, enum 1/2), a plain enum param, and a REQUIRED
1581        // param that the missing-query probe drops.
1582        let spec_json = serde_json::json!({
1583            "openapi": "3.0.0",
1584            "info": { "title": "apigee-min", "version": "1.0.0" },
1585            "paths": {
1586                "/v1/organizations": {
1587                    "post": {
1588                        "parameters": [
1589                            { "name": "$.xgafv", "in": "query",
1590                              "schema": { "type": "string", "enum": ["1", "2"] } },
1591                            { "name": "alt", "in": "query",
1592                              "schema": { "type": "string", "enum": ["json", "media"] } },
1593                            { "name": "parent", "in": "query", "required": true,
1594                              "schema": { "type": "string" } }
1595                        ],
1596                        "responses": { "200": { "description": "ok" } }
1597                    }
1598                }
1599            }
1600        });
1601        let spec_path = dir.path().join("apigee-min.json");
1602        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1603
1604        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1605        let mut f = std::fs::File::create(&jsonl_path).unwrap();
1606        let base = "https://172.22.232.2:443/v1/organizations";
1607        for line in [
1608            // Valid baseline: nothing should be reported.
1609            serde_json::json!({
1610                "label": "positive", "method": "POST",
1611                "url": format!("{base}?%24.xgafv=1&alt=json&parent=test-value"),
1612                "request_body": ""
1613            }),
1614            // owasp:sqli injects `' OR '1'='1` into the ENCODED `%24.xgafv`.
1615            serde_json::json!({
1616                "label": "owasp:sqli", "method": "POST",
1617                "url": format!("{base}?%24.xgafv=%27%20OR%20%271%27%3D%271&alt=json&parent=test-value"),
1618                "request_body": ""
1619            }),
1620            // parameters:missing-query drops the required `parent`.
1621            serde_json::json!({
1622                "label": "parameters:missing-query", "method": "POST",
1623                "url": format!("{base}?%24.xgafv=1&alt=json"),
1624                "request_body": ""
1625            }),
1626        ] {
1627            writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1628        }
1629        drop(f);
1630
1631        let n = validate_emitted_requests_with_base_path(
1632            std::slice::from_ref(&spec_path),
1633            dir.path(),
1634            None,
1635        )
1636        .await
1637        .expect("validation runs");
1638        assert!(n >= 2, "expected owasp + missing-required to be flagged, got {n}");
1639
1640        let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1641            .unwrap();
1642        let flat: serde_json::Value = serde_json::from_str(&flat).unwrap();
1643        let rows = flat.as_array().unwrap();
1644
1645        // The owasp probe must surface as a query violation on the DECODED
1646        // param name, with the DECODED value in the message (not `%27%20OR...`).
1647        let owasp = rows
1648            .iter()
1649            .find(|r| r["check_name"] == "owasp:sqli")
1650            .expect("owasp:sqli must produce a violation");
1651        assert_eq!(owasp["violation_type"], "query_value_mismatch");
1652        let msg = owasp["message"].as_str().unwrap();
1653        assert!(msg.contains("$.xgafv"), "decoded param name expected: {msg}");
1654        assert!(msg.contains("' OR '1'='1"), "decoded value expected: {msg}");
1655
1656        // The missing required param must be reported.
1657        let missing = rows
1658            .iter()
1659            .find(|r| r["check_name"] == "parameters:missing-query")
1660            .expect("missing-query must produce a violation");
1661        assert_eq!(missing["violation_type"], "query_missing_required");
1662        assert!(missing["message"].as_str().unwrap().contains("parent"));
1663
1664        // The positive probe must stay clean (no false positives).
1665        assert!(
1666            !rows.iter().any(|r| r["check_name"] == "positive"),
1667            "positive probe must not be flagged: {rows:?}"
1668        );
1669    }
1670
1671    /// Round 54 (#79) — the segment matcher must bind custom-verb path params
1672    /// (`{instance}:reportStatus`), not just bare `{name}` segments.
1673    #[test]
1674    fn segment_matcher_handles_custom_verbs() {
1675        use super::match_path_segment;
1676        // Bare placeholder.
1677        assert_eq!(match_path_segment("{name}", "abc"), Some(Some(("name", "abc".to_string()))));
1678        // Custom verb suffix — Google style.
1679        assert_eq!(
1680            match_path_segment("{instance}:reportStatus", "self-test-invalid-id:reportStatus"),
1681            Some(Some(("instance", "self-test-invalid-id".to_string())))
1682        );
1683        // Wrong verb -> no match.
1684        assert_eq!(match_path_segment("{instance}:reportStatus", "x:other"), None);
1685        // Literal segment matches only itself.
1686        assert_eq!(match_path_segment("v1", "v1"), Some(None));
1687        assert_eq!(match_path_segment("v1", "v2"), None);
1688    }
1689
1690    /// Round 54 (#79) — Srikanth on 0.3.200: OWASP violations now appear but
1691    /// parameter probes didn't. A `parameters:bad-path-param` probe hits a
1692    /// custom-verb path (`/v1/{instance}:reportStatus`); the path never matched
1693    /// the template (so validation was skipped entirely), and even when it did
1694    /// only enum/type were checked, not `pattern`/`maxLength`. This reproduces
1695    /// the probe against a constrained path param and asserts the violation.
1696    #[tokio::test]
1697    async fn emitted_requests_flag_bad_custom_verb_path_param() {
1698        let dir = tempfile::tempdir().expect("tempdir");
1699        let spec_json = serde_json::json!({
1700            "openapi": "3.0.0",
1701            "info": { "title": "apigee-min", "version": "1.0.0" },
1702            "paths": {
1703                "/v1/{instance}:reportStatus": {
1704                    "post": {
1705                        "parameters": [
1706                            { "name": "instance", "in": "path", "required": true,
1707                              "schema": { "type": "string", "maxLength": 8 } }
1708                        ],
1709                        "responses": { "200": { "description": "ok" } }
1710                    }
1711                }
1712            }
1713        });
1714        let spec_path = dir.path().join("apigee-min.json");
1715        std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1716
1717        let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1718        std::fs::write(
1719            &jsonl_path,
1720            serde_json::to_string(&serde_json::json!({
1721                "label": "parameters:bad-path-param",
1722                "method": "POST",
1723                // 20-char instance value > maxLength 8, on the custom-verb path.
1724                "url": "https://172.22.232.2:443/v1/self-test-invalid-id:reportStatus",
1725                "request_body": ""
1726            }))
1727            .unwrap()
1728                + "\n",
1729        )
1730        .unwrap();
1731
1732        let n = validate_emitted_requests_with_base_path(
1733            std::slice::from_ref(&spec_path),
1734            dir.path(),
1735            None,
1736        )
1737        .await
1738        .expect("validation runs");
1739        assert!(n >= 1, "the bad custom-verb path param must be flagged, got {n}");
1740
1741        let flat: serde_json::Value = serde_json::from_str(
1742            &std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1743                .unwrap(),
1744        )
1745        .unwrap();
1746        let row = flat
1747            .as_array()
1748            .unwrap()
1749            .iter()
1750            .find(|r| r["check_name"] == "parameters:bad-path-param")
1751            .expect("bad-path-param violation present");
1752        assert_eq!(row["violation_type"], "path_value_mismatch");
1753        let msg = row["message"].as_str().unwrap();
1754        assert!(msg.contains("instance") && msg.contains("maxLength"), "unexpected: {msg}");
1755    }
1756}