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 describe_parameter_probe(check: &str) -> String {
251 if check.starts_with("parameters:missing-query") {
252 "negative probe omitted a query parameter; not a contract breach when the \
253 parameter is optional (a required one is reported as query_missing_required). \
254 The target should still validate it."
255 .to_string()
256 } else if check.starts_with("parameters:uri-too-long") {
257 "negative probe added an oversized / undeclared query parameter; OpenAPI does \
258 not constrain URI length or forbid extra query params, so this is not a schema \
259 breach. Exercises the target's URI-length / unknown-param handling."
260 .to_string()
261 } else if check.starts_with("parameters:bad-path-param") {
262 "negative probe injected an invalid-looking path parameter value; it satisfied \
263 the declared schema (no enum/pattern/length constraint to violate), so it is not \
264 a schema breach (a constrained one is reported as path_value_mismatch)."
265 .to_string()
266 } else {
267 "parameter negative probe emitted; no client-side schema breach detected (the \
268 mutation is permitted by the contract, so rejection is the target's responsibility)."
269 .to_string()
270 }
271}
272
273fn collapse_slashes(path: &str) -> String {
277 let mut out = String::with_capacity(path.len());
278 let mut prev_slash = false;
279 for ch in path.chars() {
280 if ch == '/' {
281 if !prev_slash {
282 out.push(ch);
283 }
284 prev_slash = true;
285 } else {
286 out.push(ch);
287 prev_slash = false;
288 }
289 }
290 out
291}
292
293fn path_matches_template(concrete: &str, template: &str) -> bool {
295 let concrete_parts: Vec<&str> = concrete.split('/').collect();
296 let template_parts: Vec<&str> = template.split('/').collect();
297
298 if concrete_parts.len() != template_parts.len() {
299 return false;
300 }
301
302 concrete_parts
303 .iter()
304 .zip(template_parts.iter())
305 .all(|(c, t)| match_path_segment(t, c).is_some())
306}
307
308#[allow(clippy::too_many_arguments)]
310fn validate_request_body(
311 check_name: &str,
312 method: &str,
313 path: &str,
314 body: Option<&str>,
315 operation: &openapiv3::Operation,
316 spec: &OpenAPI,
317 violations: &mut Vec<RequestViolation>,
318) {
319 let request_body_ref = match &operation.request_body {
320 Some(rb) => rb,
321 None => {
322 return;
324 }
325 };
326
327 let request_body = match request_body_ref {
329 ReferenceOr::Item(rb) => rb,
330 ReferenceOr::Reference { reference } => {
331 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
332 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
333 Some(ReferenceOr::Item(rb)) => rb,
334 _ => return,
335 }
336 }
337 };
338
339 if request_body.required && body.is_none() {
341 violations.push(RequestViolation {
342 check_name: check_name.to_string(),
343 method: method.to_string(),
344 path: path.to_string(),
345 violation_type: "missing_required_body".to_string(),
346 message: "Spec requires a request body but none is provided in the check".to_string(),
347 });
348 return;
349 }
350
351 if let Some(body_str) = body {
353 let json_media = request_body.content.get("application/json").or_else(|| {
355 request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
356 });
357
358 if let Some(media) = json_media {
359 if let Some(schema_ref) = &media.schema {
360 let root_schema = match schema_ref {
375 ReferenceOr::Item(s) => s.clone(),
376 ReferenceOr::Reference { reference } => {
377 let name =
378 reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
379 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
380 Some(ReferenceOr::Item(s)) => s.clone(),
381 _ => return,
382 }
383 }
384 };
385
386 match serde_json::from_str::<serde_json::Value>(body_str) {
388 Ok(body_value) => {
389 match mockforge_openapi::schema_ref_resolver::build_validator(
390 &root_schema,
391 spec,
392 ) {
393 Ok(validator) => {
394 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
395 for err in errors.iter().take(5) {
396 violations.push(RequestViolation {
397 check_name: check_name.to_string(),
398 method: method.to_string(),
399 path: path.to_string(),
400 violation_type: "body_schema_violation".to_string(),
401 message: format!(
402 "Request body schema violation at {}: {}",
403 err.instance_path, err
404 ),
405 });
406 }
407 }
408 Err(_) => {
409 }
411 }
412 }
413 Err(e) => {
414 violations.push(RequestViolation {
415 check_name: check_name.to_string(),
416 method: method.to_string(),
417 path: path.to_string(),
418 violation_type: "body_not_json".to_string(),
419 message: format!("Request body is not valid JSON: {}", e),
420 });
421 }
422 }
423 }
424 }
425 }
426}
427
428#[allow(clippy::too_many_arguments)]
430fn validate_parameters(
431 check_name: &str,
432 method: &str,
433 path: &str,
434 check_path_no_query: &str,
435 check_headers: &HashMap<String, String>,
436 operation: &openapiv3::Operation,
437 path_item: &openapiv3::PathItem,
438 spec: &OpenAPI,
439 violations: &mut Vec<RequestViolation>,
440) {
441 let mut all_params = Vec::new();
443 for p in &path_item.parameters {
444 if let Some(param) = resolve_parameter(p, spec) {
445 all_params.push(param);
446 }
447 }
448 for p in &operation.parameters {
449 if let Some(param) = resolve_parameter(p, spec) {
450 all_params.push(param);
451 }
452 }
453
454 for param in &all_params {
455 let param_data = match param {
456 openapiv3::Parameter::Query { parameter_data, .. } => {
457 if !parameter_data.required {
458 continue;
459 }
460 let has_param = check_path_no_query != path
462 && path.contains(&format!("{}=", parameter_data.name));
463 if !has_param {
464 violations.push(RequestViolation {
465 check_name: check_name.to_string(),
466 method: method.to_string(),
467 path: path.to_string(),
468 violation_type: "missing_required_query_param".to_string(),
469 message: format!(
470 "Required query parameter '{}' is missing",
471 parameter_data.name
472 ),
473 });
474 }
475 continue;
476 }
477 openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
478 openapiv3::Parameter::Path { parameter_data, .. } => {
479 let _ = parameter_data;
482 continue;
483 }
484 openapiv3::Parameter::Cookie { .. } => continue,
485 };
486
487 if param_data.required {
488 let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(¶m_data.name));
489 if !has_header {
490 violations.push(RequestViolation {
491 check_name: check_name.to_string(),
492 method: method.to_string(),
493 path: path.to_string(),
494 violation_type: "missing_required_header".to_string(),
495 message: format!("Required header parameter '{}' is missing", param_data.name),
496 });
497 }
498 }
499 }
500}
501
502fn resolve_parameter<'a>(
504 param_ref: &'a ReferenceOr<openapiv3::Parameter>,
505 spec: &'a OpenAPI,
506) -> Option<&'a openapiv3::Parameter> {
507 match param_ref {
508 ReferenceOr::Item(p) => Some(p),
509 ReferenceOr::Reference { reference } => {
510 let name = reference.strip_prefix("#/components/parameters/")?;
511 match spec.components.as_ref()?.parameters.get(name)? {
512 ReferenceOr::Item(p) => Some(p),
513 _ => None,
514 }
515 }
516 }
517}
518
519fn pct_decode(s: &str) -> String {
531 urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string())
532}
533
534fn resolve_param_schema<'a>(
538 schema_ref: &'a ReferenceOr<openapiv3::Schema>,
539 spec: &'a OpenAPI,
540) -> Option<&'a openapiv3::Schema> {
541 match schema_ref {
542 ReferenceOr::Item(s) => Some(s),
543 ReferenceOr::Reference { reference } => {
544 let name = reference.strip_prefix("#/components/schemas/")?;
545 match spec.components.as_ref()?.schemas.get(name)? {
546 ReferenceOr::Item(s) => Some(s),
547 _ => None,
548 }
549 }
550 }
551}
552
553#[allow(dead_code)]
557fn resolve_schema_to_json(
558 schema_ref: &ReferenceOr<openapiv3::Schema>,
559 spec: &OpenAPI,
560) -> Option<serde_json::Value> {
561 let schema = match schema_ref {
562 ReferenceOr::Item(s) => s,
563 ReferenceOr::Reference { reference } => {
564 let name = reference.strip_prefix("#/components/schemas/")?;
565 match spec.components.as_ref()?.schemas.get(name)? {
566 ReferenceOr::Item(s) => s,
567 _ => return None,
568 }
569 }
570 };
571 serde_json::to_value(schema).ok()
572}
573
574pub async fn run_request_validation(
577 spec_files: &[std::path::PathBuf],
578 custom_checks_file: Option<&Path>,
579 base_path: Option<&str>,
580 output_dir: &Path,
581) -> Result<usize> {
582 let custom_file = match custom_checks_file {
583 Some(f) => f,
584 None => return Ok(0),
585 };
586
587 if spec_files.is_empty() {
588 return Ok(0);
589 }
590
591 let parser = SpecParser::from_file(&spec_files[0]).await?;
592 let spec = parser.spec();
593
594 let violations = validate_custom_checks(spec, custom_file, base_path)?;
595
596 if !violations.is_empty() {
597 let path = output_dir.join("conformance-request-violations.json");
598 if let Ok(json) = serde_json::to_string_pretty(&violations) {
599 let _ = std::fs::write(&path, json);
600 tracing::info!(
601 "Found {} request validation violation(s), saved to {}",
602 violations.len(),
603 path.display()
604 );
605 }
606 }
607
608 Ok(violations.len())
609}
610
611pub async fn validate_emitted_requests(
633 spec_files: &[std::path::PathBuf],
634 output_dir: &Path,
635) -> Result<usize> {
636 validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
637}
638
639pub async fn validate_emitted_requests_with_base_path(
658 spec_files: &[std::path::PathBuf],
659 output_dir: &Path,
660 base_path: Option<&str>,
661) -> Result<usize> {
662 use serde_json::Value;
663
664 if spec_files.is_empty() {
665 return Ok(0);
666 }
667 let requests_path = output_dir.join("conformance-requests.json");
668 let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
669
670 let entries: Vec<Value> = if requests_path.exists() {
680 let bytes = match std::fs::read(&requests_path) {
681 Ok(b) => b,
682 Err(_) => return Ok(0),
683 };
684 match serde_json::from_slice(&bytes) {
685 Ok(v) => v,
686 Err(_) => return Ok(0),
687 }
688 } else if self_test_jsonl_path.exists() {
689 let bytes = match std::fs::read(&self_test_jsonl_path) {
690 Ok(b) => b,
691 Err(_) => return Ok(0),
692 };
693 let text = String::from_utf8_lossy(&bytes);
694 text.lines()
695 .filter(|l| !l.is_empty())
696 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
697 .map(|case| {
698 let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
699 let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
700 let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
701 let body = case.get("request_body").cloned().unwrap_or(Value::Null);
702 let mut req = serde_json::Map::new();
703 req.insert("method".into(), Value::String(method));
704 req.insert("url".into(), Value::String(url));
705 req.insert(
706 "body".into(),
707 match body {
708 Value::String(s) => Value::String(s),
709 Value::Null => Value::String(String::new()),
710 other => other,
711 },
712 );
713 let mut out = serde_json::Map::new();
714 out.insert("check".into(), Value::String(label));
715 out.insert("request".into(), Value::Object(req));
716 Value::Object(out)
717 })
718 .collect()
719 } else {
720 return Ok(0);
721 };
722 if entries.is_empty() {
723 return Ok(0);
724 }
725
726 let parser = SpecParser::from_file(&spec_files[0]).await?;
727 let spec = parser.spec();
728 let spec_ops = build_spec_operation_map(spec);
729
730 let mut emitted_violations: Vec<RequestViolation> = Vec::new();
731
732 for entry in &entries {
733 let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
734 let req = match entry.get("request") {
735 Some(r) => r,
736 None => continue,
737 };
738 let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
739 let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
740 if method.is_empty() || url.is_empty() {
741 continue;
742 }
743 let viol_before = emitted_violations.len();
747 let (path_only, query_string) = match url.find('?') {
748 Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
749 None => (url.clone(), String::new()),
750 };
751 let path_only = if let Some(stripped) = path_only.split_once("://") {
754 match stripped.1.find('/') {
755 Some(i) => stripped.1[i..].to_string(),
756 None => "/".to_string(),
757 }
758 } else {
759 path_only
760 };
761
762 let path_only = collapse_slashes(&path_only);
769
770 let lookup_path = if let Some(bp) = base_path {
774 let bp = bp.trim_end_matches('/');
775 if !bp.is_empty() && path_only.starts_with(bp) {
776 let stripped = &path_only[bp.len()..];
777 if stripped.is_empty() {
778 "/".to_string()
779 } else {
780 stripped.to_string()
781 }
782 } else {
783 path_only.clone()
784 }
785 } else {
786 path_only.clone()
787 };
788
789 let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
790 Some(p) => p,
791 None => continue,
792 };
793 let path_item = match spec.paths.paths.get(&spec_path) {
794 Some(ReferenceOr::Item(item)) => item,
795 _ => continue,
796 };
797 let operation = match method.as_str() {
798 "GET" => path_item.get.as_ref(),
799 "POST" => path_item.post.as_ref(),
800 "PUT" => path_item.put.as_ref(),
801 "DELETE" => path_item.delete.as_ref(),
802 "PATCH" => path_item.patch.as_ref(),
803 "HEAD" => path_item.head.as_ref(),
804 "OPTIONS" => path_item.options.as_ref(),
805 _ => None,
806 };
807 let Some(operation) = operation else { continue };
808
809 let sent_query: HashMap<String, String> = query_string
822 .split('&')
823 .filter_map(|kv| {
824 let mut it = kv.splitn(2, '=');
825 let k = pct_decode(it.next()?);
826 let v = pct_decode(it.next().unwrap_or(""));
827 if k.is_empty() {
828 None
829 } else {
830 Some((k, v))
831 }
832 })
833 .collect();
834
835 let path_params: HashMap<String, String> = {
841 let mut out = HashMap::new();
842 let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
843 let template_parts: Vec<&str> = spec_path.split('/').collect();
844 if concrete_parts.len() == template_parts.len() {
845 for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
846 if let Some(Some((name, value))) = match_path_segment(t, c) {
850 out.insert(name.to_string(), pct_decode(&value));
851 }
852 }
853 }
854 out
855 };
856
857 let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
858 for p in &path_item.parameters {
859 if let Some(param) = resolve_parameter(p, spec) {
860 all_params.push(param);
861 }
862 }
863 for p in &operation.parameters {
864 if let Some(param) = resolve_parameter(p, spec) {
865 all_params.push(param);
866 }
867 }
868
869 for param in &all_params {
870 let (loc_str, name, schema_ref) = match param {
871 openapiv3::Parameter::Query { parameter_data, .. } => {
872 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
873 else {
874 continue;
875 };
876 let Some(v) = sent_query.get(¶meter_data.name) else {
877 if parameter_data.required {
883 emitted_violations.push(RequestViolation {
884 check_name: check.clone(),
885 method: method.clone(),
886 path: url.clone(),
887 violation_type: "query_missing_required".to_string(),
888 message: format!(
889 "query.{}: required parameter missing",
890 parameter_data.name
891 ),
892 });
893 }
894 continue;
895 };
896 ("query", ¶meter_data.name, (sref, v.clone()))
897 }
898 openapiv3::Parameter::Path { parameter_data, .. } => {
899 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
900 else {
901 continue;
902 };
903 let Some(v) = path_params.get(¶meter_data.name) else {
904 continue;
905 };
906 ("path", ¶meter_data.name, (sref, v.clone()))
907 }
908 _ => continue,
909 };
910 let (schema_ref, value) = schema_ref;
911 let Some(schema) = resolve_param_schema(schema_ref, spec) else {
915 continue;
916 };
917 if let Some(msg) = check_value_against_schema(&value, schema) {
918 emitted_violations.push(RequestViolation {
919 check_name: check.clone(),
920 method: method.clone(),
921 path: url.clone(),
922 violation_type: format!("{}_value_mismatch", loc_str),
923 message: format!("{}.{}: {}", loc_str, name, msg),
924 });
925 }
926 }
927
928 let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
948 if !body_str.is_empty() {
949 if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
950 validate_emitted_body(
951 &check,
952 &method,
953 &url,
954 &body_json,
955 operation,
956 spec,
957 &mut emitted_violations,
958 );
959 }
960 }
961
962 if check.starts_with("parameters:") && emitted_violations.len() == viol_before {
974 emitted_violations.push(RequestViolation {
975 check_name: check.clone(),
976 method: method.clone(),
977 path: url.clone(),
978 violation_type: "parameter_negative_probe".to_string(),
979 message: describe_parameter_probe(&check),
980 });
981 }
982 }
983
984 let dst = output_dir.join("conformance-request-violations.json");
986 let mut all: Vec<Value> = if dst.exists() {
987 match std::fs::read(&dst) {
988 Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
989 Err(_) => Vec::new(),
990 }
991 } else {
992 Vec::new()
993 };
994 for v in &emitted_violations {
995 if let Ok(val) = serde_json::to_value(v) {
996 all.push(val);
997 }
998 }
999 {
1007 let mut seen: std::collections::HashSet<(String, String, String, String, String)> =
1008 std::collections::HashSet::new();
1009 all.retain(|v| {
1010 let f = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
1011 seen.insert((
1012 f("check_name"),
1013 f("method"),
1014 f("path"),
1015 f("violation_type"),
1016 f("message"),
1017 ))
1018 });
1019 }
1020 if !all.is_empty() {
1021 if let Ok(json) = serde_json::to_string_pretty(&all) {
1022 let _ = std::fs::write(&dst, json);
1023 tracing::info!(
1024 "validate-requests: wrote {} entries to {} ({} from emitted requests)",
1025 all.len(),
1026 dst.display(),
1027 emitted_violations.len()
1028 );
1029 }
1030 }
1031
1032 let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
1041 let grouped_value = group_violations_by_request(&all);
1042 if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
1043 let _ = std::fs::write(&grouped_dst, json);
1044 }
1045
1046 let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
1057 let drill_value = group_violations_by_probe(&all);
1058 if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
1059 let _ = std::fs::write(&drill_dst, json);
1060 }
1061 Ok(emitted_violations.len())
1062}
1063
1064fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
1071 use serde_json::{Map, Value};
1072
1073 let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
1074 let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
1075 std::collections::HashMap::new();
1076
1077 let mut seen_in_probe: std::collections::HashSet<(String, String, String, String)> =
1085 std::collections::HashSet::new();
1086 for v in flat {
1087 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1088 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1089 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1090 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1091 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1092 let key = (check.clone(), method.clone(), path.clone());
1093 if !by_probe.contains_key(&key) {
1094 by_probe_order.push(key.clone());
1095 }
1096 if seen_in_probe.insert((check, method, path, format!("{vt}\u{0}{msg}"))) {
1097 by_probe.entry(key).or_default().push((vt, msg));
1098 }
1099 }
1100
1101 by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
1103
1104 let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
1105 for key in &by_probe_order {
1106 let (check, method, path) = key;
1107 let entries = by_probe.get(key).cloned().unwrap_or_default();
1108 let mut row = Map::new();
1109 row.insert("check_name".into(), Value::String(check.clone()));
1110 row.insert("method".into(), Value::String(method.clone()));
1111 row.insert("path".into(), Value::String(path.clone()));
1112 row.insert(
1113 "violation_count".into(),
1114 Value::Number(serde_json::Number::from(entries.len())),
1115 );
1116 for (i, (vt, msg)) in entries.iter().enumerate() {
1117 let mut entry = Map::new();
1118 entry.insert("violation_type".into(), Value::String(vt.clone()));
1119 entry.insert("message".into(), Value::String(msg.clone()));
1120 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1121 }
1122 rows.push(Value::Object(row));
1123 }
1124 Value::Array(rows)
1125}
1126
1127fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
1148 use serde_json::{Map, Value};
1149
1150 let mut order: Vec<(String, String)> = Vec::new();
1151 let mut checks_by_key: std::collections::HashMap<(String, String), Vec<String>> =
1152 std::collections::HashMap::new();
1153 let mut viols_by_key: std::collections::HashMap<(String, String), Vec<(String, String)>> =
1154 std::collections::HashMap::new();
1155 let mut seen_check: std::collections::HashSet<(String, String, String)> =
1158 std::collections::HashSet::new();
1159 let mut seen_viol: std::collections::HashSet<(String, String, String)> =
1160 std::collections::HashSet::new();
1161
1162 for v in flat {
1163 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
1164 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
1165 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
1166 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
1167 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
1168 let key = (method.clone(), path.clone());
1169 if !checks_by_key.contains_key(&key) && !viols_by_key.contains_key(&key) {
1170 order.push(key.clone());
1171 }
1172 if !check.is_empty() && seen_check.insert((method.clone(), path.clone(), check.clone())) {
1173 checks_by_key.entry(key.clone()).or_default().push(check);
1174 }
1175 if seen_viol.insert((method.clone(), path.clone(), format!("{vt}\u{0}{msg}"))) {
1176 viols_by_key.entry(key).or_default().push((vt, msg));
1177 }
1178 }
1179
1180 let mut rows: Vec<Value> = Vec::with_capacity(order.len());
1181 for key in &order {
1182 let (method, path) = key;
1183 let checks = checks_by_key.get(key).cloned().unwrap_or_default();
1184 let viols = viols_by_key.get(key).cloned().unwrap_or_default();
1185 let mut row = Map::new();
1186 row.insert(
1187 "checks".into(),
1188 Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
1189 );
1190 let dominant_prefix: &str = viols
1195 .first()
1196 .map(|(vt, _)| {
1197 if vt.starts_with("query_") {
1198 "param:query"
1199 } else if vt.starts_with("body_") {
1200 "body:"
1201 } else if vt.starts_with("path_") {
1202 "param:path"
1203 } else if vt.starts_with("header_") {
1204 "param:header"
1205 } else {
1206 ""
1207 }
1208 })
1209 .unwrap_or("");
1210 let best_check = if !dominant_prefix.is_empty() {
1211 checks
1212 .iter()
1213 .find(|c| c.starts_with(dominant_prefix))
1214 .cloned()
1215 .or_else(|| checks.first().cloned())
1216 .unwrap_or_default()
1217 } else {
1218 checks.first().cloned().unwrap_or_default()
1219 };
1220 row.insert("check_name".into(), Value::String(best_check));
1221 row.insert("method".into(), Value::String(method.clone()));
1222 row.insert("path".into(), Value::String(path.clone()));
1223 row.insert("violation_count".into(), Value::Number(serde_json::Number::from(viols.len())));
1224 for (i, (vt, msg)) in viols.iter().enumerate() {
1225 let mut entry = Map::new();
1226 entry.insert("violation_type".into(), Value::String(vt.clone()));
1227 entry.insert("message".into(), Value::String(msg.clone()));
1228 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1229 }
1230 rows.push(Value::Object(row));
1231 }
1232 Value::Array(rows)
1233}
1234
1235fn validate_emitted_body(
1248 check: &str,
1249 method: &str,
1250 url: &str,
1251 body: &serde_json::Value,
1252 operation: &openapiv3::Operation,
1253 spec: &OpenAPI,
1254 violations: &mut Vec<RequestViolation>,
1255) {
1256 let Some(request_body_ref) = &operation.request_body else {
1258 return;
1259 };
1260 let request_body = match request_body_ref {
1261 ReferenceOr::Item(rb) => rb,
1262 ReferenceOr::Reference { reference } => {
1263 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
1264 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
1265 Some(ReferenceOr::Item(rb)) => rb,
1266 _ => return,
1267 }
1268 }
1269 };
1270
1271 let json_media = request_body
1274 .content
1275 .get("application/json")
1276 .or_else(|| request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v));
1277 let Some(media) = json_media else {
1278 return;
1279 };
1280 let Some(schema_ref) = &media.schema else {
1281 return;
1282 };
1283
1284 let root_schema = match schema_ref {
1288 ReferenceOr::Item(s) => s.clone(),
1289 ReferenceOr::Reference { reference } => {
1290 let name = reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
1291 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
1292 Some(ReferenceOr::Item(s)) => s.clone(),
1293 _ => return,
1294 }
1295 }
1296 };
1297
1298 let Ok(validator) = mockforge_openapi::schema_ref_resolver::build_validator(&root_schema, spec)
1299 else {
1300 return;
1302 };
1303 for err in validator.iter_errors(body).take(5) {
1304 let loc = err.instance_path.to_string();
1305 let loc = if loc.is_empty() { "$".to_string() } else { loc };
1306 violations.push(RequestViolation {
1307 check_name: check.to_string(),
1308 method: method.to_string(),
1309 path: url.to_string(),
1310 violation_type: "body_schema_violation".to_string(),
1311 message: format!("body{}: {}", loc, err),
1312 });
1313 }
1314}
1315
1316fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1323 use openapiv3::{SchemaKind, Type};
1324
1325 let SchemaKind::Type(t) = &schema.schema_kind else {
1326 return None;
1327 };
1328 match t {
1329 Type::String(s) => {
1330 if !s.enumeration.is_empty() {
1331 let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1332 if !allowed.iter().any(|a| a == value) {
1333 let quoted: Vec<String> =
1334 allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1335 return Some(format!(
1336 "value \"{}\" is not one of {}",
1337 value,
1338 quoted.join(" or ")
1339 ));
1340 }
1341 }
1342 let len = value.chars().count();
1347 if let Some(min) = s.min_length {
1348 if len < min {
1349 return Some(format!("value \"{value}\" is shorter than minLength {min}"));
1350 }
1351 }
1352 if let Some(max) = s.max_length {
1353 if len > max {
1354 return Some(format!("value \"{value}\" is longer than maxLength {max}"));
1355 }
1356 }
1357 if let Some(pat) = &s.pattern {
1358 if let Ok(re) = regex::Regex::new(pat) {
1361 if !re.is_match(value) {
1362 return Some(format!("value \"{value}\" does not match pattern /{pat}/"));
1363 }
1364 }
1365 }
1366 None
1367 }
1368 Type::Integer(_) => {
1369 if value.parse::<i64>().is_err() {
1370 Some(format!("value \"{}\" is not of type \"integer\"", value))
1371 } else {
1372 None
1373 }
1374 }
1375 Type::Number(_) => {
1376 if value.parse::<f64>().is_err() {
1377 Some(format!("value \"{}\" is not of type \"number\"", value))
1378 } else {
1379 None
1380 }
1381 }
1382 Type::Boolean(_) => match value {
1383 "true" | "false" => None,
1384 _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1385 },
1386 _ => None,
1387 }
1388}
1389
1390#[cfg(test)]
1391mod grouping_tests {
1392 use super::{group_violations_by_probe, group_violations_by_request};
1393 use serde_json::json;
1394
1395 fn viol(check: &str, method: &str, path: &str, vt: &str, msg: &str) -> serde_json::Value {
1397 json!({
1398 "check_name": check,
1399 "method": method,
1400 "path": path,
1401 "violation_type": vt,
1402 "message": msg,
1403 })
1404 }
1405
1406 #[test]
1413 fn by_request_unions_all_checks_for_a_url() {
1414 let path = "https://host/v1/organizations?alt=test-value&prettyPrint=test-value";
1415 let flat = vec![
1416 viol(
1417 "request-body:type-mismatch:billingType",
1418 "POST",
1419 path,
1420 "body_type_mismatch",
1421 "body.billingType: expected string",
1422 ),
1423 viol(
1424 "owasp:ldap-injection",
1425 "POST",
1426 path,
1427 "query_value_mismatch",
1428 "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1429 ),
1430 viol(
1431 "owasp:ldap-injection",
1432 "POST",
1433 path,
1434 "query_value_mismatch",
1435 "query.prettyPrint: value \"test-value\" is not of type \"boolean\"",
1436 ),
1437 ];
1438
1439 let out = group_violations_by_request(&flat);
1440 let rows = out.as_array().expect("array");
1441 assert_eq!(rows.len(), 1, "expected a single by-request row per URL");
1443 let row = &rows[0];
1444 assert_eq!(row["violation_count"], 3);
1445 let checks: Vec<&str> =
1446 row["checks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
1447 assert!(checks.contains(&"owasp:ldap-injection"), "owasp check must appear: {checks:?}");
1448 assert!(
1449 checks.iter().any(|c| c.starts_with("request-body:")),
1450 "body check must appear: {checks:?}"
1451 );
1452 }
1453
1454 #[test]
1458 fn by_probe_dedups_repeated_iterations() {
1459 let path = "https://host/v1/organizations?alt=test-value";
1460 let mut flat = Vec::new();
1461 for _ in 0..22 {
1462 flat.push(viol(
1463 "owasp:ldap-injection",
1464 "POST",
1465 path,
1466 "query_value_mismatch",
1467 "query.alt: value \"test-value\" is not one of \"json\" or \"media\"",
1468 ));
1469 }
1470
1471 let out = group_violations_by_probe(&flat);
1472 let rows = out.as_array().expect("array");
1473 assert_eq!(rows.len(), 1, "one probe row");
1474 assert_eq!(rows[0]["violation_count"], 1, "22 identical iterations collapse to 1");
1475 assert!(rows[0].get("violation_1").is_some());
1476 assert!(rows[0].get("violation_2").is_none(), "no duplicate violation_2");
1477 }
1478
1479 #[test]
1482 fn by_request_dedups_repeated_iterations() {
1483 let path = "https://host/v1/widgets";
1484 let mut flat = Vec::new();
1485 for _ in 0..22 {
1486 flat.push(viol(
1487 "request-body:type-mismatch:name",
1488 "POST",
1489 path,
1490 "body_type_mismatch",
1491 "body.name: expected string",
1492 ));
1493 }
1494 let out = group_violations_by_request(&flat);
1495 let rows = out.as_array().unwrap();
1496 assert_eq!(rows.len(), 1);
1497 assert_eq!(rows[0]["violation_count"], 1, "duplicate iterations collapse");
1498 let checks = rows[0]["checks"].as_array().unwrap();
1499 assert_eq!(checks.len(), 1, "the same check listed once");
1500 }
1501
1502 #[test]
1504 fn by_request_keeps_distinct_urls_separate() {
1505 let flat = vec![
1506 viol("c1", "POST", "https://host/a", "body_type_mismatch", "a"),
1507 viol("c2", "GET", "https://host/b", "query_value_mismatch", "b"),
1508 ];
1509 let out = group_violations_by_request(&flat);
1510 assert_eq!(out.as_array().unwrap().len(), 2);
1511 }
1512}
1513
1514#[cfg(test)]
1515mod emitted_body_tests {
1516 use super::validate_emitted_requests_with_base_path;
1517 use std::io::Write;
1518
1519 #[tokio::test]
1537 async fn emitted_requests_validate_ref_bodied_negatives() {
1538 let dir = tempfile::tempdir().expect("tempdir");
1539
1540 let spec_json = serde_json::json!({
1544 "openapi": "3.0.0",
1545 "info": { "title": "apigee-min", "version": "1.0.0" },
1546 "paths": {
1547 "/v1/organizations": {
1548 "post": {
1549 "requestBody": {
1550 "content": {
1551 "application/json": {
1552 "schema": { "$ref": "#/components/schemas/Organization" }
1553 }
1554 }
1555 },
1556 "responses": { "200": { "description": "ok" } }
1557 }
1558 }
1559 },
1560 "components": {
1561 "schemas": {
1562 "Organization": {
1563 "type": "object",
1564 "properties": {
1565 "analyticsRegion": { "type": "string" },
1566 "displayName": { "type": "string" }
1567 }
1568 }
1569 }
1570 }
1571 });
1572 let spec_path = dir.path().join("apigee-min.json");
1573 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1574
1575 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1580 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1581 let base = "https://172.22.232.2:443/v1/organizations?alt=json";
1582 for line in [
1583 serde_json::json!({
1584 "label": "positive", "method": "POST", "url": base, "request_body": "{}"
1585 }),
1586 serde_json::json!({
1587 "label": "request-body:type-mismatch:analyticsRegion",
1588 "method": "POST", "url": base,
1589 "request_body": "{\"analyticsRegion\":12345}"
1590 }),
1591 serde_json::json!({
1592 "label": "request-body:wrong-type",
1593 "method": "POST", "url": base, "request_body": "[]"
1594 }),
1595 ] {
1596 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1597 }
1598 drop(f);
1599
1600 let n = validate_emitted_requests_with_base_path(
1601 std::slice::from_ref(&spec_path),
1602 dir.path(),
1603 None,
1604 )
1605 .await
1606 .expect("validation runs");
1607
1608 assert!(n >= 2, "expected the two request-body negatives to be flagged, got {n}");
1609
1610 let by_request = std::fs::read_to_string(
1612 dir.path().join("conformance-request-violations-by-request.json"),
1613 )
1614 .unwrap();
1615 let by_request: serde_json::Value = serde_json::from_str(&by_request).unwrap();
1616 assert!(
1617 !by_request.as_array().unwrap().is_empty(),
1618 "by-request file must not be empty for a spec with $ref request bodies"
1619 );
1620
1621 let by_probe = std::fs::read_to_string(
1622 dir.path().join("conformance-request-violations-by-probe.json"),
1623 )
1624 .unwrap();
1625 let by_probe: serde_json::Value = serde_json::from_str(&by_probe).unwrap();
1626 assert!(!by_probe.as_array().unwrap().is_empty(), "by-probe file must not be empty");
1627
1628 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1630 .unwrap();
1631 assert!(
1632 flat.contains("analyticsRegion"),
1633 "the number-where-string probe must be reported: {flat}"
1634 );
1635 }
1636
1637 #[tokio::test]
1655 async fn emitted_requests_flag_encoded_query_and_missing_required() {
1656 let dir = tempfile::tempdir().expect("tempdir");
1657
1658 let spec_json = serde_json::json!({
1662 "openapi": "3.0.0",
1663 "info": { "title": "apigee-min", "version": "1.0.0" },
1664 "paths": {
1665 "/v1/organizations": {
1666 "post": {
1667 "parameters": [
1668 { "name": "$.xgafv", "in": "query",
1669 "schema": { "type": "string", "enum": ["1", "2"] } },
1670 { "name": "alt", "in": "query",
1671 "schema": { "type": "string", "enum": ["json", "media"] } },
1672 { "name": "parent", "in": "query", "required": true,
1673 "schema": { "type": "string" } }
1674 ],
1675 "responses": { "200": { "description": "ok" } }
1676 }
1677 }
1678 }
1679 });
1680 let spec_path = dir.path().join("apigee-min.json");
1681 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1682
1683 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1684 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1685 let base = "https://172.22.232.2:443/v1/organizations";
1686 for line in [
1687 serde_json::json!({
1689 "label": "positive", "method": "POST",
1690 "url": format!("{base}?%24.xgafv=1&alt=json&parent=test-value"),
1691 "request_body": ""
1692 }),
1693 serde_json::json!({
1695 "label": "owasp:sqli", "method": "POST",
1696 "url": format!("{base}?%24.xgafv=%27%20OR%20%271%27%3D%271&alt=json&parent=test-value"),
1697 "request_body": ""
1698 }),
1699 serde_json::json!({
1701 "label": "parameters:missing-query", "method": "POST",
1702 "url": format!("{base}?%24.xgafv=1&alt=json"),
1703 "request_body": ""
1704 }),
1705 ] {
1706 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1707 }
1708 drop(f);
1709
1710 let n = validate_emitted_requests_with_base_path(
1711 std::slice::from_ref(&spec_path),
1712 dir.path(),
1713 None,
1714 )
1715 .await
1716 .expect("validation runs");
1717 assert!(n >= 2, "expected owasp + missing-required to be flagged, got {n}");
1718
1719 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1720 .unwrap();
1721 let flat: serde_json::Value = serde_json::from_str(&flat).unwrap();
1722 let rows = flat.as_array().unwrap();
1723
1724 let owasp = rows
1727 .iter()
1728 .find(|r| r["check_name"] == "owasp:sqli")
1729 .expect("owasp:sqli must produce a violation");
1730 assert_eq!(owasp["violation_type"], "query_value_mismatch");
1731 let msg = owasp["message"].as_str().unwrap();
1732 assert!(msg.contains("$.xgafv"), "decoded param name expected: {msg}");
1733 assert!(msg.contains("' OR '1'='1"), "decoded value expected: {msg}");
1734
1735 let missing = rows
1737 .iter()
1738 .find(|r| r["check_name"] == "parameters:missing-query")
1739 .expect("missing-query must produce a violation");
1740 assert_eq!(missing["violation_type"], "query_missing_required");
1741 assert!(missing["message"].as_str().unwrap().contains("parent"));
1742
1743 assert!(
1745 !rows.iter().any(|r| r["check_name"] == "positive"),
1746 "positive probe must not be flagged: {rows:?}"
1747 );
1748 }
1749
1750 #[test]
1753 fn collapse_slashes_normalises_double_slashes() {
1754 use super::collapse_slashes;
1755 assert_eq!(collapse_slashes("//v1/organizations"), "/v1/organizations");
1756 assert_eq!(collapse_slashes("/v1//x///y"), "/v1/x/y");
1757 assert_eq!(collapse_slashes("/v1/organizations"), "/v1/organizations");
1758 assert_eq!(collapse_slashes("/"), "/");
1759 }
1760
1761 #[tokio::test]
1767 async fn double_slashed_url_still_validates() {
1768 let dir = tempfile::tempdir().expect("tempdir");
1769 let spec_json = serde_json::json!({
1770 "openapi": "3.0.0",
1771 "info": { "title": "apigee-min", "version": "1.0.0" },
1772 "paths": {
1773 "/v1/organizations": {
1774 "post": {
1775 "requestBody": {
1776 "content": {
1777 "application/json": {
1778 "schema": {
1779 "type": "object",
1780 "properties": { "analyticsRegion": { "type": "string" } }
1781 }
1782 }
1783 }
1784 },
1785 "responses": { "200": { "description": "ok" } }
1786 }
1787 }
1788 }
1789 });
1790 let spec_path = dir.path().join("apigee-min.json");
1791 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1792
1793 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1794 std::fs::write(
1795 &jsonl_path,
1796 serde_json::to_string(&serde_json::json!({
1797 "label": "request-body:type-mismatch:analyticsRegion",
1798 "method": "POST",
1799 "url": "https://172.22.232.2:443//v1/organizations",
1801 "request_body": "{\"analyticsRegion\":12345}"
1802 }))
1803 .unwrap()
1804 + "\n",
1805 )
1806 .unwrap();
1807
1808 let n = validate_emitted_requests_with_base_path(
1809 std::slice::from_ref(&spec_path),
1810 dir.path(),
1811 Some("/"),
1813 )
1814 .await
1815 .expect("validation runs");
1816 assert!(n >= 1, "double-slashed URL must still match and flag the body, got {n}");
1817 let flat = std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1818 .unwrap();
1819 assert!(flat.contains("analyticsRegion"), "body probe must be reported: {flat}");
1820 }
1821
1822 #[test]
1825 fn segment_matcher_handles_custom_verbs() {
1826 use super::match_path_segment;
1827 assert_eq!(match_path_segment("{name}", "abc"), Some(Some(("name", "abc".to_string()))));
1829 assert_eq!(
1831 match_path_segment("{instance}:reportStatus", "self-test-invalid-id:reportStatus"),
1832 Some(Some(("instance", "self-test-invalid-id".to_string())))
1833 );
1834 assert_eq!(match_path_segment("{instance}:reportStatus", "x:other"), None);
1836 assert_eq!(match_path_segment("v1", "v1"), Some(None));
1838 assert_eq!(match_path_segment("v1", "v2"), None);
1839 }
1840
1841 #[tokio::test]
1848 async fn emitted_requests_flag_bad_custom_verb_path_param() {
1849 let dir = tempfile::tempdir().expect("tempdir");
1850 let spec_json = serde_json::json!({
1851 "openapi": "3.0.0",
1852 "info": { "title": "apigee-min", "version": "1.0.0" },
1853 "paths": {
1854 "/v1/{instance}:reportStatus": {
1855 "post": {
1856 "parameters": [
1857 { "name": "instance", "in": "path", "required": true,
1858 "schema": { "type": "string", "maxLength": 8 } }
1859 ],
1860 "responses": { "200": { "description": "ok" } }
1861 }
1862 }
1863 }
1864 });
1865 let spec_path = dir.path().join("apigee-min.json");
1866 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1867
1868 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1869 std::fs::write(
1870 &jsonl_path,
1871 serde_json::to_string(&serde_json::json!({
1872 "label": "parameters:bad-path-param",
1873 "method": "POST",
1874 "url": "https://172.22.232.2:443/v1/self-test-invalid-id:reportStatus",
1876 "request_body": ""
1877 }))
1878 .unwrap()
1879 + "\n",
1880 )
1881 .unwrap();
1882
1883 let n = validate_emitted_requests_with_base_path(
1884 std::slice::from_ref(&spec_path),
1885 dir.path(),
1886 None,
1887 )
1888 .await
1889 .expect("validation runs");
1890 assert!(n >= 1, "the bad custom-verb path param must be flagged, got {n}");
1891
1892 let flat: serde_json::Value = serde_json::from_str(
1893 &std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1894 .unwrap(),
1895 )
1896 .unwrap();
1897 let row = flat
1898 .as_array()
1899 .unwrap()
1900 .iter()
1901 .find(|r| r["check_name"] == "parameters:bad-path-param")
1902 .expect("bad-path-param violation present");
1903 assert_eq!(row["violation_type"], "path_value_mismatch");
1904 let msg = row["message"].as_str().unwrap();
1905 assert!(msg.contains("instance") && msg.contains("maxLength"), "unexpected: {msg}");
1906 }
1907
1908 #[tokio::test]
1915 async fn parameter_negatives_are_recorded_even_when_spec_valid() {
1916 let dir = tempfile::tempdir().expect("tempdir");
1917 let spec_json = serde_json::json!({
1918 "openapi": "3.0.0",
1919 "info": { "title": "apigee-min", "version": "1.0.0" },
1920 "paths": {
1921 "/v1/organizations": {
1922 "post": {
1923 "parameters": [
1924 { "name": "$.xgafv", "in": "query",
1925 "schema": { "type": "string", "enum": ["1", "2"] } }
1926 ],
1927 "responses": { "200": { "description": "ok" } }
1928 }
1929 }
1930 }
1931 });
1932 let spec_path = dir.path().join("apigee-min.json");
1933 std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec_json).unwrap()).unwrap();
1934
1935 let base = "https://172.22.232.2:443/v1/organizations";
1936 let jsonl_path = dir.path().join("conformance-self-test-requests.jsonl");
1937 let mut f = std::fs::File::create(&jsonl_path).unwrap();
1938 use std::io::Write as _;
1939 for line in [
1940 serde_json::json!({ "label": "parameters:missing-query", "method": "POST",
1942 "url": base, "request_body": "" }),
1943 serde_json::json!({ "label": "parameters:uri-too-long", "method": "POST",
1945 "url": format!("{base}?p=xxxxxxxxxxxxxxxxxxxx"), "request_body": "" }),
1946 serde_json::json!({ "label": "owasp:sqli", "method": "POST",
1948 "url": format!("{base}?%24.xgafv=%27%20OR%201%3D1"), "request_body": "" }),
1949 ] {
1950 writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
1951 }
1952 drop(f);
1953
1954 validate_emitted_requests_with_base_path(
1955 std::slice::from_ref(&spec_path),
1956 dir.path(),
1957 None,
1958 )
1959 .await
1960 .expect("runs");
1961 let flat: serde_json::Value = serde_json::from_str(
1962 &std::fs::read_to_string(dir.path().join("conformance-request-violations.json"))
1963 .unwrap(),
1964 )
1965 .unwrap();
1966 let rows = flat.as_array().unwrap();
1967
1968 let param_probes: Vec<&serde_json::Value> = rows
1970 .iter()
1971 .filter(|r| r["violation_type"] == "parameter_negative_probe")
1972 .collect();
1973 assert!(
1974 param_probes.iter().any(|r| r["check_name"] == "parameters:missing-query"),
1975 "missing-query probe must be recorded: {rows:?}"
1976 );
1977 assert!(
1978 param_probes.iter().any(|r| r["check_name"] == "parameters:uri-too-long"),
1979 "uri-too-long probe must be recorded"
1980 );
1981 let owasp = rows.iter().find(|r| r["check_name"] == "owasp:sqli").expect("owasp present");
1983 assert_eq!(owasp["violation_type"], "query_value_mismatch");
1984 }
1985}