1use 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#[derive(Debug, Serialize)]
18pub struct RequestViolation {
19 pub check_name: String,
21 pub method: String,
23 pub path: String,
25 pub violation_type: String,
27 pub message: String,
29}
30
31pub 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 let spec_ops = build_spec_operation_map(spec);
44
45 for check in &config.custom_checks {
46 let check_path = check.path.split('?').next().unwrap_or(&check.path);
48
49 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 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 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 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
132type SpecOperationMap = HashMap<String, Vec<String>>; fn 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
167fn find_matching_spec_path(
170 check_path: &str,
171 spec_ops: &SpecOperationMap,
172 base_path: Option<&str>,
173) -> Option<String> {
174 if spec_ops.contains_key(check_path) {
176 return Some(check_path.to_string());
177 }
178
179 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 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
204fn path_matches_template(concrete: &str, template: &str) -> bool {
206 let concrete_parts: Vec<&str> = concrete.split('/').collect();
207 let template_parts: Vec<&str> = template.split('/').collect();
208
209 if concrete_parts.len() != template_parts.len() {
210 return false;
211 }
212
213 concrete_parts
214 .iter()
215 .zip(template_parts.iter())
216 .all(|(c, t)| t.starts_with('{') && t.ends_with('}') || c == t)
217}
218
219#[allow(clippy::too_many_arguments)]
221fn validate_request_body(
222 check_name: &str,
223 method: &str,
224 path: &str,
225 body: Option<&str>,
226 operation: &openapiv3::Operation,
227 spec: &OpenAPI,
228 violations: &mut Vec<RequestViolation>,
229) {
230 let request_body_ref = match &operation.request_body {
231 Some(rb) => rb,
232 None => {
233 return;
235 }
236 };
237
238 let request_body = match request_body_ref {
240 ReferenceOr::Item(rb) => rb,
241 ReferenceOr::Reference { reference } => {
242 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
243 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
244 Some(ReferenceOr::Item(rb)) => rb,
245 _ => return,
246 }
247 }
248 };
249
250 if request_body.required && body.is_none() {
252 violations.push(RequestViolation {
253 check_name: check_name.to_string(),
254 method: method.to_string(),
255 path: path.to_string(),
256 violation_type: "missing_required_body".to_string(),
257 message: "Spec requires a request body but none is provided in the check".to_string(),
258 });
259 return;
260 }
261
262 if let Some(body_str) = body {
264 let json_media = request_body.content.get("application/json").or_else(|| {
266 request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
267 });
268
269 if let Some(media) = json_media {
270 if let Some(schema_ref) = &media.schema {
271 let root_schema = match schema_ref {
286 ReferenceOr::Item(s) => s.clone(),
287 ReferenceOr::Reference { reference } => {
288 let name =
289 reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
290 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
291 Some(ReferenceOr::Item(s)) => s.clone(),
292 _ => return,
293 }
294 }
295 };
296
297 match serde_json::from_str::<serde_json::Value>(body_str) {
299 Ok(body_value) => {
300 match mockforge_openapi::schema_ref_resolver::build_validator(
301 &root_schema,
302 spec,
303 ) {
304 Ok(validator) => {
305 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
306 for err in errors.iter().take(5) {
307 violations.push(RequestViolation {
308 check_name: check_name.to_string(),
309 method: method.to_string(),
310 path: path.to_string(),
311 violation_type: "body_schema_violation".to_string(),
312 message: format!(
313 "Request body schema violation at {}: {}",
314 err.instance_path, err
315 ),
316 });
317 }
318 }
319 Err(_) => {
320 }
322 }
323 }
324 Err(e) => {
325 violations.push(RequestViolation {
326 check_name: check_name.to_string(),
327 method: method.to_string(),
328 path: path.to_string(),
329 violation_type: "body_not_json".to_string(),
330 message: format!("Request body is not valid JSON: {}", e),
331 });
332 }
333 }
334 }
335 }
336 }
337}
338
339#[allow(clippy::too_many_arguments)]
341fn validate_parameters(
342 check_name: &str,
343 method: &str,
344 path: &str,
345 check_path_no_query: &str,
346 check_headers: &HashMap<String, String>,
347 operation: &openapiv3::Operation,
348 path_item: &openapiv3::PathItem,
349 spec: &OpenAPI,
350 violations: &mut Vec<RequestViolation>,
351) {
352 let mut all_params = Vec::new();
354 for p in &path_item.parameters {
355 if let Some(param) = resolve_parameter(p, spec) {
356 all_params.push(param);
357 }
358 }
359 for p in &operation.parameters {
360 if let Some(param) = resolve_parameter(p, spec) {
361 all_params.push(param);
362 }
363 }
364
365 for param in &all_params {
366 let param_data = match param {
367 openapiv3::Parameter::Query { parameter_data, .. } => {
368 if !parameter_data.required {
369 continue;
370 }
371 let has_param = check_path_no_query != path
373 && path.contains(&format!("{}=", parameter_data.name));
374 if !has_param {
375 violations.push(RequestViolation {
376 check_name: check_name.to_string(),
377 method: method.to_string(),
378 path: path.to_string(),
379 violation_type: "missing_required_query_param".to_string(),
380 message: format!(
381 "Required query parameter '{}' is missing",
382 parameter_data.name
383 ),
384 });
385 }
386 continue;
387 }
388 openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
389 openapiv3::Parameter::Path { parameter_data, .. } => {
390 let _ = parameter_data;
393 continue;
394 }
395 openapiv3::Parameter::Cookie { .. } => continue,
396 };
397
398 if param_data.required {
399 let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(¶m_data.name));
400 if !has_header {
401 violations.push(RequestViolation {
402 check_name: check_name.to_string(),
403 method: method.to_string(),
404 path: path.to_string(),
405 violation_type: "missing_required_header".to_string(),
406 message: format!("Required header parameter '{}' is missing", param_data.name),
407 });
408 }
409 }
410 }
411}
412
413fn resolve_parameter<'a>(
415 param_ref: &'a ReferenceOr<openapiv3::Parameter>,
416 spec: &'a OpenAPI,
417) -> Option<&'a openapiv3::Parameter> {
418 match param_ref {
419 ReferenceOr::Item(p) => Some(p),
420 ReferenceOr::Reference { reference } => {
421 let name = reference.strip_prefix("#/components/parameters/")?;
422 match spec.components.as_ref()?.parameters.get(name)? {
423 ReferenceOr::Item(p) => Some(p),
424 _ => None,
425 }
426 }
427 }
428}
429
430fn pct_decode(s: &str) -> String {
442 urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
443}
444
445fn resolve_param_schema<'a>(
449 schema_ref: &'a ReferenceOr<openapiv3::Schema>,
450 spec: &'a OpenAPI,
451) -> Option<&'a openapiv3::Schema> {
452 match schema_ref {
453 ReferenceOr::Item(s) => Some(s),
454 ReferenceOr::Reference { reference } => {
455 let name = reference.strip_prefix("#/components/schemas/")?;
456 match spec.components.as_ref()?.schemas.get(name)? {
457 ReferenceOr::Item(s) => Some(s),
458 _ => None,
459 }
460 }
461 }
462}
463
464#[allow(dead_code)]
468fn resolve_schema_to_json(
469 schema_ref: &ReferenceOr<openapiv3::Schema>,
470 spec: &OpenAPI,
471) -> Option<serde_json::Value> {
472 let schema = match schema_ref {
473 ReferenceOr::Item(s) => s,
474 ReferenceOr::Reference { reference } => {
475 let name = reference.strip_prefix("#/components/schemas/")?;
476 match spec.components.as_ref()?.schemas.get(name)? {
477 ReferenceOr::Item(s) => s,
478 _ => return None,
479 }
480 }
481 };
482 serde_json::to_value(schema).ok()
483}
484
485pub async fn run_request_validation(
488 spec_files: &[std::path::PathBuf],
489 custom_checks_file: Option<&Path>,
490 base_path: Option<&str>,
491 output_dir: &Path,
492) -> Result<usize> {
493 let custom_file = match custom_checks_file {
494 Some(f) => f,
495 None => return Ok(0),
496 };
497
498 if spec_files.is_empty() {
499 return Ok(0);
500 }
501
502 let parser = SpecParser::from_file(&spec_files[0]).await?;
503 let spec = parser.spec();
504
505 let violations = validate_custom_checks(spec, custom_file, base_path)?;
506
507 if !violations.is_empty() {
508 let path = output_dir.join("conformance-request-violations.json");
509 if let Ok(json) = serde_json::to_string_pretty(&violations) {
510 let _ = std::fs::write(&path, json);
511 tracing::info!(
512 "Found {} request validation violation(s), saved to {}",
513 violations.len(),
514 path.display()
515 );
516 }
517 }
518
519 Ok(violations.len())
520}
521
522pub async fn validate_emitted_requests(
544 spec_files: &[std::path::PathBuf],
545 output_dir: &Path,
546) -> Result<usize> {
547 validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
548}
549
550pub async fn validate_emitted_requests_with_base_path(
569 spec_files: &[std::path::PathBuf],
570 output_dir: &Path,
571 base_path: Option<&str>,
572) -> Result<usize> {
573 use serde_json::Value;
574
575 if spec_files.is_empty() {
576 return Ok(0);
577 }
578 let requests_path = output_dir.join("conformance-requests.json");
579 let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
580
581 let entries: Vec<Value> = if requests_path.exists() {
591 let bytes = match std::fs::read(&requests_path) {
592 Ok(b) => b,
593 Err(_) => return Ok(0),
594 };
595 match serde_json::from_slice(&bytes) {
596 Ok(v) => v,
597 Err(_) => return Ok(0),
598 }
599 } else if self_test_jsonl_path.exists() {
600 let bytes = match std::fs::read(&self_test_jsonl_path) {
601 Ok(b) => b,
602 Err(_) => return Ok(0),
603 };
604 let text = String::from_utf8_lossy(&bytes);
605 text.lines()
606 .filter(|l| !l.is_empty())
607 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
608 .map(|case| {
609 let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
610 let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
611 let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
612 let body = case.get("request_body").cloned().unwrap_or(Value::Null);
613 let mut req = serde_json::Map::new();
614 req.insert("method".into(), Value::String(method));
615 req.insert("url".into(), Value::String(url));
616 req.insert(
617 "body".into(),
618 match body {
619 Value::String(s) => Value::String(s),
620 Value::Null => Value::String(String::new()),
621 other => other,
622 },
623 );
624 let mut out = serde_json::Map::new();
625 out.insert("check".into(), Value::String(label));
626 out.insert("request".into(), Value::Object(req));
627 Value::Object(out)
628 })
629 .collect()
630 } else {
631 return Ok(0);
632 };
633 if entries.is_empty() {
634 return Ok(0);
635 }
636
637 let parser = SpecParser::from_file(&spec_files[0]).await?;
638 let spec = parser.spec();
639 let spec_ops = build_spec_operation_map(spec);
640
641 let mut emitted_violations: Vec<RequestViolation> = Vec::new();
642
643 for entry in &entries {
644 let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
645 let req = match entry.get("request") {
646 Some(r) => r,
647 None => continue,
648 };
649 let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
650 let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
651 if method.is_empty() || url.is_empty() {
652 continue;
653 }
654 let (path_only, query_string) = match url.find('?') {
655 Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
656 None => (url.clone(), String::new()),
657 };
658 let path_only = if let Some(stripped) = path_only.split_once("://") {
661 match stripped.1.find('/') {
662 Some(i) => stripped.1[i..].to_string(),
663 None => "/".to_string(),
664 }
665 } else {
666 path_only
667 };
668
669 let lookup_path = if let Some(bp) = base_path {
673 let bp = bp.trim_end_matches('/');
674 if !bp.is_empty() && path_only.starts_with(bp) {
675 let stripped = &path_only[bp.len()..];
676 if stripped.is_empty() {
677 "/".to_string()
678 } else {
679 stripped.to_string()
680 }
681 } else {
682 path_only.clone()
683 }
684 } else {
685 path_only.clone()
686 };
687
688 let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
689 Some(p) => p,
690 None => continue,
691 };
692 let path_item = match spec.paths.paths.get(&spec_path) {
693 Some(ReferenceOr::Item(item)) => item,
694 _ => continue,
695 };
696 let operation = match method.as_str() {
697 "GET" => path_item.get.as_ref(),
698 "POST" => path_item.post.as_ref(),
699 "PUT" => path_item.put.as_ref(),
700 "DELETE" => path_item.delete.as_ref(),
701 "PATCH" => path_item.patch.as_ref(),
702 "HEAD" => path_item.head.as_ref(),
703 "OPTIONS" => path_item.options.as_ref(),
704 _ => None,
705 };
706 let Some(operation) = operation else { continue };
707
708 let sent_query: HashMap<String, String> = query_string
721 .split('&')
722 .filter_map(|kv| {
723 let mut it = kv.splitn(2, '=');
724 let k = pct_decode(it.next()?);
725 let v = pct_decode(it.next().unwrap_or(""));
726 if k.is_empty() {
727 None
728 } else {
729 Some((k, v))
730 }
731 })
732 .collect();
733
734 let path_params: HashMap<String, String> = {
740 let mut out = HashMap::new();
741 let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
742 let template_parts: Vec<&str> = spec_path.split('/').collect();
743 if concrete_parts.len() == template_parts.len() {
744 for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
745 if t.starts_with('{') && t.ends_with('}') {
746 let name = &t[1..t.len() - 1];
747 out.insert(name.to_string(), pct_decode(c));
749 }
750 }
751 }
752 out
753 };
754
755 let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
756 for p in &path_item.parameters {
757 if let Some(param) = resolve_parameter(p, spec) {
758 all_params.push(param);
759 }
760 }
761 for p in &operation.parameters {
762 if let Some(param) = resolve_parameter(p, spec) {
763 all_params.push(param);
764 }
765 }
766
767 for param in &all_params {
768 let (loc_str, name, schema_ref) = match param {
769 openapiv3::Parameter::Query { parameter_data, .. } => {
770 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
771 else {
772 continue;
773 };
774 let Some(v) = sent_query.get(¶meter_data.name) else {
775 if parameter_data.required {
781 emitted_violations.push(RequestViolation {
782 check_name: check.clone(),
783 method: method.clone(),
784 path: url.clone(),
785 violation_type: "query_missing_required".to_string(),
786 message: format!(
787 "query.{}: required parameter missing",
788 parameter_data.name
789 ),
790 });
791 }
792 continue;
793 };
794 ("query", ¶meter_data.name, (sref, v.clone()))
795 }
796 openapiv3::Parameter::Path { parameter_data, .. } => {
797 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
798 else {
799 continue;
800 };
801 let Some(v) = path_params.get(¶meter_data.name) else {
802 continue;
803 };
804 ("path", ¶meter_data.name, (sref, v.clone()))
805 }
806 _ => continue,
807 };
808 let (schema_ref, value) = schema_ref;
809 let Some(schema) = resolve_param_schema(schema_ref, spec) else {
813 continue;
814 };
815 if let Some(msg) = check_value_against_schema(&value, schema) {
816 emitted_violations.push(RequestViolation {
817 check_name: check.clone(),
818 method: method.clone(),
819 path: url.clone(),
820 violation_type: format!("{}_value_mismatch", loc_str),
821 message: format!("{}.{}: {}", loc_str, name, msg),
822 });
823 }
824 }
825
826 let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
846 if !body_str.is_empty() {
847 if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
848 validate_emitted_body(
849 &check,
850 &method,
851 &url,
852 &body_json,
853 operation,
854 spec,
855 &mut emitted_violations,
856 );
857 }
858 }
859 }
860
861 let dst = output_dir.join("conformance-request-violations.json");
863 let mut all: Vec<Value> = if dst.exists() {
864 match std::fs::read(&dst) {
865 Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
866 Err(_) => Vec::new(),
867 }
868 } else {
869 Vec::new()
870 };
871 for v in &emitted_violations {
872 if let Ok(val) = serde_json::to_value(v) {
873 all.push(val);
874 }
875 }
876 {
884 let mut seen: std::collections::HashSet<(String, String, String, String, String)> =
885 std::collections::HashSet::new();
886 all.retain(|v| {
887 let f = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
888 seen.insert((
889 f("check_name"),
890 f("method"),
891 f("path"),
892 f("violation_type"),
893 f("message"),
894 ))
895 });
896 }
897 if !all.is_empty() {
898 if let Ok(json) = serde_json::to_string_pretty(&all) {
899 let _ = std::fs::write(&dst, json);
900 tracing::info!(
901 "validate-requests: wrote {} entries to {} ({} from emitted requests)",
902 all.len(),
903 dst.display(),
904 emitted_violations.len()
905 );
906 }
907 }
908
909 let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
918 let grouped_value = group_violations_by_request(&all);
919 if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
920 let _ = std::fs::write(&grouped_dst, json);
921 }
922
923 let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
934 let drill_value = group_violations_by_probe(&all);
935 if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
936 let _ = std::fs::write(&drill_dst, json);
937 }
938 Ok(emitted_violations.len())
939}
940
941fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
948 use serde_json::{Map, Value};
949
950 let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
951 let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
952 std::collections::HashMap::new();
953
954 let mut seen_in_probe: std::collections::HashSet<(String, String, String, String)> =
962 std::collections::HashSet::new();
963 for v in flat {
964 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
965 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
966 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
967 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
968 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
969 let key = (check.clone(), method.clone(), path.clone());
970 if !by_probe.contains_key(&key) {
971 by_probe_order.push(key.clone());
972 }
973 if seen_in_probe.insert((check, method, path, format!("{vt}\u{0}{msg}"))) {
974 by_probe.entry(key).or_default().push((vt, msg));
975 }
976 }
977
978 by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
980
981 let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
982 for key in &by_probe_order {
983 let (check, method, path) = key;
984 let entries = by_probe.get(key).cloned().unwrap_or_default();
985 let mut row = Map::new();
986 row.insert("check_name".into(), Value::String(check.clone()));
987 row.insert("method".into(), Value::String(method.clone()));
988 row.insert("path".into(), Value::String(path.clone()));
989 row.insert(
990 "violation_count".into(),
991 Value::Number(serde_json::Number::from(entries.len())),
992 );
993 for (i, (vt, msg)) in entries.iter().enumerate() {
994 let mut entry = Map::new();
995 entry.insert("violation_type".into(), Value::String(vt.clone()));
996 entry.insert("message".into(), Value::String(msg.clone()));
997 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
998 }
999 rows.push(Value::Object(row));
1000 }
1001 Value::Array(rows)
1002}
1003
1004fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
1025 use serde_json::{Map, Value};
1026
1027 let mut order: Vec<(String, String)> = Vec::new();
1028 let mut checks_by_key: std::collections::HashMap<(String, String), Vec<String>> =
1029 std::collections::HashMap::new();
1030 let mut viols_by_key: std::collections::HashMap<(String, String), Vec<(String, String)>> =
1031 std::collections::HashMap::new();
1032 let mut seen_check: std::collections::HashSet<(String, String, String)> =
1035 std::collections::HashSet::new();
1036 let mut seen_viol: std::collections::HashSet<(String, String, String)> =
1037 std::collections::HashSet::new();
1038
1039 for v in flat {
1040 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1041 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1042 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1043 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1044 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1045 let key = (method.clone(), path.clone());
1046 if !checks_by_key.contains_key(&key) && !viols_by_key.contains_key(&key) {
1047 order.push(key.clone());
1048 }
1049 if !check.is_empty() && seen_check.insert((method.clone(), path.clone(), check.clone())) {
1050 checks_by_key.entry(key.clone()).or_default().push(check);
1051 }
1052 if seen_viol.insert((method.clone(), path.clone(), format!("{vt}\u{0}{msg}"))) {
1053 viols_by_key.entry(key).or_default().push((vt, msg));
1054 }
1055 }
1056
1057 let mut rows: Vec<Value> = Vec::with_capacity(order.len());
1058 for key in &order {
1059 let (method, path) = key;
1060 let checks = checks_by_key.get(key).cloned().unwrap_or_default();
1061 let viols = viols_by_key.get(key).cloned().unwrap_or_default();
1062 let mut row = Map::new();
1063 row.insert(
1064 "checks".into(),
1065 Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
1066 );
1067 let dominant_prefix: &str = viols
1072 .first()
1073 .map(|(vt, _)| {
1074 if vt.starts_with("query_") {
1075 "param:query"
1076 } else if vt.starts_with("body_") {
1077 "body:"
1078 } else if vt.starts_with("path_") {
1079 "param:path"
1080 } else if vt.starts_with("header_") {
1081 "param:header"
1082 } else {
1083 ""
1084 }
1085 })
1086 .unwrap_or("");
1087 let best_check = if !dominant_prefix.is_empty() {
1088 checks
1089 .iter()
1090 .find(|c| c.starts_with(dominant_prefix))
1091 .cloned()
1092 .or_else(|| checks.first().cloned())
1093 .unwrap_or_default()
1094 } else {
1095 checks.first().cloned().unwrap_or_default()
1096 };
1097 row.insert("check_name".into(), Value::String(best_check));
1098 row.insert("method".into(), Value::String(method.clone()));
1099 row.insert("path".into(), Value::String(path.clone()));
1100 row.insert("violation_count".into(), Value::Number(serde_json::Number::from(viols.len())));
1101 for (i, (vt, msg)) in viols.iter().enumerate() {
1102 let mut entry = Map::new();
1103 entry.insert("violation_type".into(), Value::String(vt.clone()));
1104 entry.insert("message".into(), Value::String(msg.clone()));
1105 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1106 }
1107 rows.push(Value::Object(row));
1108 }
1109 Value::Array(rows)
1110}
1111
1112fn validate_emitted_body(
1125 check: &str,
1126 method: &str,
1127 url: &str,
1128 body: &serde_json::Value,
1129 operation: &openapiv3::Operation,
1130 spec: &OpenAPI,
1131 violations: &mut Vec<RequestViolation>,
1132) {
1133 let Some(request_body_ref) = &operation.request_body else {
1135 return;
1136 };
1137 let request_body = match request_body_ref {
1138 ReferenceOr::Item(rb) => rb,
1139 ReferenceOr::Reference { reference } => {
1140 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
1141 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
1142 Some(ReferenceOr::Item(rb)) => rb,
1143 _ => return,
1144 }
1145 }
1146 };
1147
1148 let json_media = request_body
1151 .content
1152 .get("application/json")
1153 .or_else(|| request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v));
1154 let Some(media) = json_media else {
1155 return;
1156 };
1157 let Some(schema_ref) = &media.schema else {
1158 return;
1159 };
1160
1161 let root_schema = match schema_ref {
1165 ReferenceOr::Item(s) => s.clone(),
1166 ReferenceOr::Reference { reference } => {
1167 let name = reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
1168 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
1169 Some(ReferenceOr::Item(s)) => s.clone(),
1170 _ => return,
1171 }
1172 }
1173 };
1174
1175 let Ok(validator) = mockforge_openapi::schema_ref_resolver::build_validator(&root_schema, spec)
1176 else {
1177 return;
1179 };
1180 for err in validator.iter_errors(body).take(5) {
1181 let loc = err.instance_path.to_string();
1182 let loc = if loc.is_empty() { "$".to_string() } else { loc };
1183 violations.push(RequestViolation {
1184 check_name: check.to_string(),
1185 method: method.to_string(),
1186 path: url.to_string(),
1187 violation_type: "body_schema_violation".to_string(),
1188 message: format!("body{}: {}", loc, err),
1189 });
1190 }
1191}
1192
1193fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1200 use openapiv3::{SchemaKind, Type};
1201
1202 let SchemaKind::Type(t) = &schema.schema_kind else {
1203 return None;
1204 };
1205 match t {
1206 Type::String(s) => {
1207 if !s.enumeration.is_empty() {
1208 let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1209 if !allowed.iter().any(|a| a == value) {
1210 let quoted: Vec<String> =
1211 allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1212 return Some(format!(
1213 "value \"{}\" is not one of {}",
1214 value,
1215 quoted.join(" or ")
1216 ));
1217 }
1218 }
1219 None
1220 }
1221 Type::Integer(_) => {
1222 if value.parse::<i64>().is_err() {
1223 Some(format!("value \"{}\" is not of type \"integer\"", value))
1224 } else {
1225 None
1226 }
1227 }
1228 Type::Number(_) => {
1229 if value.parse::<f64>().is_err() {
1230 Some(format!("value \"{}\" is not of type \"number\"", value))
1231 } else {
1232 None
1233 }
1234 }
1235 Type::Boolean(_) => match value {
1236 "true" | "false" => None,
1237 _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1238 },
1239 _ => None,
1240 }
1241}
1242
1243#[cfg(test)]
1244mod grouping_tests {
1245 use super::{group_violations_by_probe, group_violations_by_request};
1246 use serde_json::json;
1247
1248 fn viol(check: &str, method: &str, path: &str, vt: &str, msg: &str) -> serde_json::Value {
1250 json!({
1251 "check_name": check,
1252 "method": method,
1253 "path": path,
1254 "violation_type": vt,
1255 "message": msg,
1256 })
1257 }
1258
1259 #[test]
1266 fn by_request_unions_all_checks_for_a_url() {
1267 let path = "https://host/v1/organizations?alt=test-value&prettyPrint=test-value";
1268 let flat = vec![
1269 viol(
1270 "request-body:type-mismatch:billingType",
1271 "POST",
1272 path,
1273 "body_type_mismatch",
1274 "body.billingType: expected string",
1275 ),
1276 viol(
1277 "owasp:ldap-injection",
1278 "POST",
1279 path,
1280 "query_value_mismatch",
1281 "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1282 ),
1283 viol(
1284 "owasp:ldap-injection",
1285 "POST",
1286 path,
1287 "query_value_mismatch",
1288 "query.prettyPrint: value \"test-value\" is not of type \"boolean\"",
1289 ),
1290 ];
1291
1292 let out = group_violations_by_request(&flat);
1293 let rows = out.as_array().expect("array");
1294 assert_eq!(rows.len(), 1, "expected a single by-request row per URL");
1296 let row = &rows[0];
1297 assert_eq!(row["violation_count"], 3);
1298 let checks: Vec<&str> =
1299 row["checks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
1300 assert!(checks.contains(&"owasp:ldap-injection"), "owasp check must appear: {checks:?}");
1301 assert!(
1302 checks.iter().any(|c| c.starts_with("request-body:")),
1303 "body check must appear: {checks:?}"
1304 );
1305 }
1306
1307 #[test]
1311 fn by_probe_dedups_repeated_iterations() {
1312 let path = "https://host/v1/organizations?alt=test-value";
1313 let mut flat = Vec::new();
1314 for _ in 0..22 {
1315 flat.push(viol(
1316 "owasp:ldap-injection",
1317 "POST",
1318 path,
1319 "query_value_mismatch",
1320 "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1321 ));
1322 }
1323
1324 let out = group_violations_by_probe(&flat);
1325 let rows = out.as_array().expect("array");
1326 assert_eq!(rows.len(), 1, "one probe row");
1327 assert_eq!(rows[0]["violation_count"], 1, "22 identical iterations collapse to 1");
1328 assert!(rows[0].get("violation_1").is_some());
1329 assert!(rows[0].get("violation_2").is_none(), "no duplicate violation_2");
1330 }
1331
1332 #[test]
1335 fn by_request_dedups_repeated_iterations() {
1336 let path = "https://host/v1/widgets";
1337 let mut flat = Vec::new();
1338 for _ in 0..22 {
1339 flat.push(viol(
1340 "request-body:type-mismatch:name",
1341 "POST",
1342 path,
1343 "body_type_mismatch",
1344 "body.name: expected string",
1345 ));
1346 }
1347 let out = group_violations_by_request(&flat);
1348 let rows = out.as_array().unwrap();
1349 assert_eq!(rows.len(), 1);
1350 assert_eq!(rows[0]["violation_count"], 1, "duplicate iterations collapse");
1351 let checks = rows[0]["checks"].as_array().unwrap();
1352 assert_eq!(checks.len(), 1, "the same check listed once");
1353 }
1354
1355 #[test]
1357 fn by_request_keeps_distinct_urls_separate() {
1358 let flat = vec![
1359 viol("c1", "POST", "https://host/a", "body_type_mismatch", "a"),
1360 viol("c2", "GET", "https://host/b", "query_value_mismatch", "b"),
1361 ];
1362 let out = group_violations_by_request(&flat);
1363 assert_eq!(out.as_array().unwrap().len(), 2);
1364 }
1365}
1366
1367#[cfg(test)]
1368mod emitted_body_tests {
1369 use super::validate_emitted_requests_with_base_path;
1370 use std::io::Write;
1371
1372 #[tokio::test]
1390 async fn emitted_requests_validate_ref_bodied_negatives() {
1391 let dir = tempfile::tempdir().expect("tempdir");
1392
1393 let spec_json = serde_json::json!({
1397 "openapi": "3.0.0",
1398 "info": { "title": "apigee-min", "version": "1.0.0" },
1399 "paths": {
1400 "/v1/organizations": {
1401 "post": {
1402 "requestBody": {
1403 "content": {
1404 "application/json": {
1405 "schema": { "$ref": "#/components/schemas/Organization" }
1406 }
1407 }
1408 },
1409 "responses": { "200": { "description": "ok" } }
1410 }
1411 }
1412 },
1413 "components": {
1414 "schemas": {
1415 "Organization": {
1416 "type": "object",
1417 "properties": {
1418 "analyticsRegion": { "type": "string" },
1419 "displayName": { "type": "string" }
1420 }
1421 }
1422 }
1423 }
1424 });
1425 let spec_path = dir.path().join("apigee-min.json");
1426 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1427
1428 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1433 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1434 let base = "https://172.22.232.2:443/v1/organizations?alt=json";
1435 for line in [
1436 serde_json::json!({
1437 "label": "positive", "method": "POST", "url": base, "request_body": "{}"
1438 }),
1439 serde_json::json!({
1440 "label": "request-body:type-mismatch:analyticsRegion",
1441 "method": "POST", "url": base,
1442 "request_body": "{\"analyticsRegion\":12345}"
1443 }),
1444 serde_json::json!({
1445 "label": "request-body:wrong-type",
1446 "method": "POST", "url": base, "request_body": "[]"
1447 }),
1448 ] {
1449 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1450 }
1451 drop(f);
1452
1453 let n = validate_emitted_requests_with_base_path(
1454 std::slice::from_ref(&spec_path),
1455 dir.path(),
1456 None,
1457 )
1458 .await
1459 .expect("validation runs");
1460
1461 assert!(n >= 2, "expected the two request-body negatives to be flagged, got {n}");
1462
1463 let by_request = std::fs::read_to_string(
1465 dir.path().join("conformance-request-violations-by-request.json"),
1466 )
1467 .unwrap();
1468 let by_request: serde_json::Value = serde_json::from_str(&by_request).unwrap();
1469 assert!(
1470 !by_request.as_array().unwrap().is_empty(),
1471 "by-request file must not be empty for a spec with $ref request bodies"
1472 );
1473
1474 let by_probe = std::fs::read_to_string(
1475 dir.path().join("conformance-request-violations-by-probe.json"),
1476 )
1477 .unwrap();
1478 let by_probe: serde_json::Value = serde_json::from_str(&by_probe).unwrap();
1479 assert!(!by_probe.as_array().unwrap().is_empty(), "by-probe file must not be empty");
1480
1481 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1483 .unwrap();
1484 assert!(
1485 flat.contains("analyticsRegion"),
1486 "the number-where-string probe must be reported: {flat}"
1487 );
1488 }
1489
1490 #[tokio::test]
1508 async fn emitted_requests_flag_encoded_query_and_missing_required() {
1509 let dir = tempfile::tempdir().expect("tempdir");
1510
1511 let spec_json = serde_json::json!({
1515 "openapi": "3.0.0",
1516 "info": { "title": "apigee-min", "version": "1.0.0" },
1517 "paths": {
1518 "/v1/organizations": {
1519 "post": {
1520 "parameters": [
1521 { "name": "$.xgafv", "in": "query",
1522 "schema": { "type": "string", "enum": ["1", "2"] } },
1523 { "name": "alt", "in": "query",
1524 "schema": { "type": "string", "enum": ["json", "media"] } },
1525 { "name": "parent", "in": "query", "required": true,
1526 "schema": { "type": "string" } }
1527 ],
1528 "responses": { "200": { "description": "ok" } }
1529 }
1530 }
1531 }
1532 });
1533 let spec_path = dir.path().join("apigee-min.json");
1534 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1535
1536 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1537 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1538 let base = "https://172.22.232.2:443/v1/organizations";
1539 for line in [
1540 serde_json::json!({
1542 "label": "positive", "method": "POST",
1543 "url": format!("{base}?%24.xgafv=1&alt=json&parent=test-value"),
1544 "request_body": ""
1545 }),
1546 serde_json::json!({
1548 "label": "owasp:sqli", "method": "POST",
1549 "url": format!("{base}?%24.xgafv=%27%20OR%20%271%27%3D%271&alt=json&parent=test-value"),
1550 "request_body": ""
1551 }),
1552 serde_json::json!({
1554 "label": "parameters:missing-query", "method": "POST",
1555 "url": format!("{base}?%24.xgafv=1&alt=json"),
1556 "request_body": ""
1557 }),
1558 ] {
1559 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1560 }
1561 drop(f);
1562
1563 let n = validate_emitted_requests_with_base_path(
1564 std::slice::from_ref(&spec_path),
1565 dir.path(),
1566 None,
1567 )
1568 .await
1569 .expect("validation runs");
1570 assert!(n >= 2, "expected owasp + missing-required to be flagged, got {n}");
1571
1572 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1573 .unwrap();
1574 let flat: serde_json::Value = serde_json::from_str(&flat).unwrap();
1575 let rows = flat.as_array().unwrap();
1576
1577 let owasp = rows
1580 .iter()
1581 .find(|r| r["check_name"] == "owasp:sqli")
1582 .expect("owasp:sqli must produce a violation");
1583 assert_eq!(owasp["violation_type"], "query_value_mismatch");
1584 let msg = owasp["message"].as_str().unwrap();
1585 assert!(msg.contains("$.xgafv"), "decoded param name expected: {msg}");
1586 assert!(msg.contains("' OR '1'='1"), "decoded value expected: {msg}");
1587
1588 let missing = rows
1590 .iter()
1591 .find(|r| r["check_name"] == "parameters:missing-query")
1592 .expect("missing-query must produce a violation");
1593 assert_eq!(missing["violation_type"], "query_missing_required");
1594 assert!(missing["message"].as_str().unwrap().contains("parent"));
1595
1596 assert!(
1598 !rows.iter().any(|r| r["check_name"] == "positive"),
1599 "positive probe must not be flagged: {rows:?}"
1600 );
1601 }
1602}