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 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 _ => {
238 if template_seg == concrete_seg {
239 Some(None)
240 } else {
241 None
242 }
243 }
244 }
245}
246
247fn 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#[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 return;
278 }
279 };
280
281 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 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 let Some(body_str) = body {
307 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 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 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 }
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#[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 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 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 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(¶m_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
456fn 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
473fn pct_decode(s: &str) -> String {
485 urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
486}
487
488fn 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#[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
528pub 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
565pub 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
593pub 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 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 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 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 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 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 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) = ¶meter_data.format
815 else {
816 continue;
817 };
818 let Some(v) = sent_query.get(¶meter_data.name) else {
819 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", ¶meter_data.name, (sref, v.clone()))
839 }
840 openapiv3::Parameter::Path { parameter_data, .. } => {
841 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
842 else {
843 continue;
844 };
845 let Some(v) = path_params.get(¶meter_data.name) else {
846 continue;
847 };
848 ("path", ¶meter_data.name, (sref, v.clone()))
849 }
850 _ => continue,
851 };
852 let (schema_ref, value) = schema_ref;
853 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 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 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 {
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 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 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
985fn 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 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 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
1048fn 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 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 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
1156fn 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 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 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 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 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
1237fn 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 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 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 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 #[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 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 #[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 #[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 #[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 #[tokio::test]
1458 async fn emitted_requests_validate_ref_bodied_negatives() {
1459 let dir = tempfile::tempdir().expect("tempdir");
1460
1461 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 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 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 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 #[tokio::test]
1576 async fn emitted_requests_flag_encoded_query_and_missing_required() {
1577 let dir = tempfile::tempdir().expect("tempdir");
1578
1579 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 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 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 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 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 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 assert!(
1666 !rows.iter().any(|r| r["check_name"] == "positive"),
1667 "positive probe must not be flagged: {rows:?}"
1668 );
1669 }
1670
1671 #[test]
1674 fn segment_matcher_handles_custom_verbs() {
1675 use super::match_path_segment;
1676 assert_eq!(match_path_segment("{name}", "abc"), Some(Some(("name", "abc".to_string()))));
1678 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 assert_eq!(match_path_segment("{instance}:reportStatus", "x:other"), None);
1685 assert_eq!(match_path_segment("v1", "v1"), Some(None));
1687 assert_eq!(match_path_segment("v1", "v2"), None);
1688 }
1689
1690 #[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 "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}