1use super::executor::SchemaViolation;
4use super::spec::ConformanceFeature;
5use crate::error::{BenchError, Result};
6use crate::owasp_api::categories::OwaspCategory;
7use colored::*;
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct OwaspCoverageEntry {
14 pub id: String,
16 pub name: String,
18 pub tested: bool,
20 pub all_passed: bool,
22 pub via_categories: Vec<String>,
24}
25
26#[derive(Debug, Clone, Default)]
28pub struct CategoryResult {
29 pub passed: usize,
30 pub failed: usize,
31}
32
33impl CategoryResult {
34 pub fn total(&self) -> usize {
35 self.passed + self.failed
36 }
37
38 pub fn rate(&self) -> f64 {
39 if self.total() == 0 {
40 0.0
41 } else {
42 (self.passed as f64 / self.total() as f64) * 100.0
43 }
44 }
45}
46
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct FailureDetail {
50 pub check: String,
52 pub request: FailureRequest,
54 pub response: FailureResponse,
56 pub expected: String,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub schema_violations: Vec<SchemaViolation>,
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct FailureRequest {
66 #[serde(default)]
68 pub method: String,
69 #[serde(default)]
71 pub url: String,
72 #[serde(default, deserialize_with = "deserialize_headers")]
74 pub headers: HashMap<String, String>,
75 #[serde(default)]
77 pub body: String,
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct FailureResponse {
83 #[serde(default)]
85 pub status: u16,
86 #[serde(default, deserialize_with = "deserialize_headers")]
88 pub headers: HashMap<String, String>,
89 #[serde(default)]
91 pub body: String,
92}
93
94fn deserialize_headers<'de, D>(
96 deserializer: D,
97) -> std::result::Result<HashMap<String, String>, D::Error>
98where
99 D: serde::Deserializer<'de>,
100{
101 use serde::Deserialize;
102 let map: HashMap<String, serde_json::Value> = HashMap::deserialize(deserializer)?;
103 Ok(map
104 .into_iter()
105 .map(|(k, v)| {
106 let val = match &v {
107 serde_json::Value::String(s) => s.clone(),
108 serde_json::Value::Array(arr) => {
109 arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
110 }
111 other => other.to_string(),
112 };
113 (k, val)
114 })
115 .collect())
116}
117
118#[allow(dead_code)]
122fn extract_custom_base_name(check_name: &str) -> String {
123 let after_prefix = &check_name[7..];
125 if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
126 check_name[..7 + pos].to_string()
127 } else {
128 check_name.to_string()
129 }
130}
131
132fn is_primary_custom_check(check_name: &str) -> bool {
139 if !check_name.starts_with("custom:") {
140 return false;
141 }
142 let after_prefix = &check_name[7..];
143 !after_prefix.contains(":header:") && !after_prefix.contains(":body:")
144}
145
146pub struct ConformanceReport {
148 check_results: HashMap<String, (u64, u64)>,
150 failure_details: Vec<FailureDetail>,
152}
153
154impl ConformanceReport {
155 pub fn from_results(
158 check_results: HashMap<String, (u64, u64)>,
159 failure_details: Vec<FailureDetail>,
160 ) -> Self {
161 Self {
162 check_results,
163 failure_details,
164 }
165 }
166
167 pub fn to_json(&self) -> serde_json::Value {
172 let mut checks = serde_json::Map::new();
173 for (name, (passes, fails)) in &self.check_results {
174 checks.insert(
175 name.clone(),
176 serde_json::json!({
177 "passes": passes,
178 "fails": fails,
179 }),
180 );
181 }
182
183 let by_cat = self.by_category();
185 let mut categories_json = serde_json::Map::new();
186 for (cat_name, cat_result) in &by_cat {
187 categories_json.insert(
188 (*cat_name).to_string(),
189 serde_json::json!({
190 "passed": cat_result.passed,
191 "total": cat_result.total(),
192 "rate": cat_result.rate(),
193 }),
194 );
195 }
196
197 let total_passed: usize = by_cat.values().map(|r| r.passed).sum();
199 let total: usize = by_cat.values().map(|r| r.total()).sum();
200 let overall_rate = if total == 0 {
201 0.0
202 } else {
203 (total_passed as f64 / total as f64) * 100.0
204 };
205
206 let failures: Vec<serde_json::Value> = self
208 .failure_details
209 .iter()
210 .map(|d| {
211 let category = Self::category_for_check(&d.check);
212 serde_json::json!({
213 "check_name": d.check,
214 "category": category,
215 "expected": d.expected,
216 "actual": format!("status {}", d.response.status),
217 "details": format!("{} {}", d.request.method, d.request.url),
218 })
219 })
220 .collect();
221
222 let mut result = serde_json::json!({
223 "checks": checks,
224 "summary": {
225 "total_checks": total,
226 "passed": total_passed,
227 "failed": total - total_passed,
228 "overall_rate": overall_rate,
229 },
230 "categories": categories_json,
231 "failures": failures,
232 });
233
234 if !self.failure_details.is_empty() {
236 result["failure_details"] = serde_json::to_value(&self.failure_details)
237 .unwrap_or(serde_json::Value::Array(Vec::new()));
238 }
239 result
240 }
241
242 fn category_for_check(check_name: &str) -> &'static str {
244 let prefix = check_name.split(':').next().unwrap_or("");
245 match prefix {
246 "param" => "Parameters",
247 "body" => "Request Bodies",
248 "response" => "Response Codes",
249 "schema" => "Schema Types",
250 "compose" => "Composition",
251 "format" => "String Formats",
252 "constraint" => "Constraints",
253 "security" => "Security",
254 "method" => "HTTP Methods",
255 "content" => "Content Types",
256 "validation" | "response_validation" => "Response Validation",
257 "custom" => "Custom",
258 _ => "Other",
259 }
260 }
261
262 pub fn failure_details(&self) -> &[FailureDetail] {
264 &self.failure_details
265 }
266
267 pub fn from_file(path: &Path) -> Result<Self> {
271 let content = std::fs::read_to_string(path)
272 .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
273 let mut report = Self::from_json(&content)?;
274
275 if let Some(parent) = path.parent() {
277 let details_path = parent.join("conformance-failure-details.json");
278 if details_path.exists() {
279 if let Ok(details_json) = std::fs::read_to_string(&details_path) {
280 if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
281 report.failure_details = details;
282 }
283 }
284 }
285 }
286
287 Ok(report)
288 }
289
290 pub fn from_json(json_str: &str) -> Result<Self> {
292 let json: serde_json::Value = serde_json::from_str(json_str)
293 .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
294
295 let mut check_results = HashMap::new();
296
297 if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
298 for (name, result) in checks {
299 let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
300 let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
301 check_results.insert(name.clone(), (passes, fails));
302 }
303 }
304
305 Ok(Self {
306 check_results,
307 failure_details: Vec::new(),
308 })
309 }
310
311 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
316 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
317
318 for cat in ConformanceFeature::categories() {
320 categories.insert(cat, CategoryResult::default());
321 }
322
323 for feature in ConformanceFeature::all() {
327 let check_name = feature.check_name();
328 let category = feature.category();
329
330 let entry = categories.entry(category).or_default();
331
332 if let Some((passes, fails)) = self.check_results.get(check_name) {
334 if *fails == 0 && *passes > 0 {
335 entry.passed += 1;
336 } else {
337 entry.failed += 1;
338 }
339 } else {
340 let prefix = format!("{}:", check_name);
342 for (name, (passes, fails)) in &self.check_results {
343 if name.starts_with(&prefix) {
344 if *fails == 0 && *passes > 0 {
345 entry.passed += 1;
346 } else {
347 entry.failed += 1;
348 }
349 }
350 }
351 }
353 }
354
355 let custom_entry = categories.entry("Custom").or_default();
376 for (name, (passes, fails)) in &self.check_results {
377 if name.starts_with("custom:") && is_primary_custom_check(name) {
378 custom_entry.passed += *passes as usize;
379 custom_entry.failed += *fails as usize;
380 }
381 }
382
383 categories
384 }
385
386 pub fn print_report(&self) {
388 self.print_report_with_options(false);
389 }
390
391 pub fn print_report_with_options(&self, all_operations: bool) {
393 let categories = self.by_category();
394
395 let total_possible = ConformanceFeature::all().len();
397 let active_cats: usize = ConformanceFeature::categories()
398 .iter()
399 .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
400 .count();
401 let detected: usize =
402 categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
403
404 println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
405 println!("{}", "=".repeat(64).bright_green());
406
407 println!(
408 "{}",
409 format!(
410 "Spec Analysis: {} of {} features detected across {} categories",
411 detected, total_possible, active_cats
412 )
413 .bright_cyan()
414 );
415 println!();
416
417 println!(
418 "{:<20} {:>8} {:>8} {:>8} {:>8}",
419 "Category".bold(),
420 "Passed".green().bold(),
421 "Failed".red().bold(),
422 "Total".bold(),
423 "Rate".bold()
424 );
425 println!("{}", "-".repeat(64));
426
427 let mut total_passed = 0usize;
428 let mut total_failed = 0usize;
429 let mut empty_categories: Vec<&str> = Vec::new();
430
431 let all_cat_names: Vec<&str> = {
433 let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
434 if categories.get("Custom").is_some_and(|r| r.total() > 0) {
435 cats.push("Custom");
436 }
437 cats
438 };
439
440 for cat_name in &all_cat_names {
441 if let Some(result) = categories.get(cat_name) {
442 let total = result.total();
443 if total == 0 {
444 println!(
446 "{:<20} {:>8} {:>8} {:>8} {:>8}",
447 cat_name.bright_black(),
448 "-".bright_black(),
449 "-".bright_black(),
450 "-".bright_black(),
451 "not in spec".bright_black()
452 );
453 empty_categories.push(cat_name);
454 continue;
455 }
456 total_passed += result.passed;
457 total_failed += result.failed;
458
459 let rate_str = format!("{:.0}%", result.rate());
460 let rate_colored = if result.rate() >= 100.0 {
461 rate_str.green()
462 } else if result.rate() >= 80.0 {
463 rate_str.yellow()
464 } else {
465 rate_str.red()
466 };
467
468 println!(
469 "{:<20} {:>8} {:>8} {:>8} {:>8}",
470 cat_name,
471 result.passed.to_string().green(),
472 result.failed.to_string().red(),
473 total,
474 rate_colored
475 );
476 }
477 }
478
479 println!("{}", "=".repeat(64).bright_green());
480
481 let grand_total = total_passed + total_failed;
482 let overall_rate = if grand_total > 0 {
483 (total_passed as f64 / grand_total as f64) * 100.0
484 } else {
485 0.0
486 };
487 let rate_str = format!("{:.0}%", overall_rate);
488 let rate_colored = if overall_rate >= 100.0 {
489 rate_str.green()
490 } else if overall_rate >= 80.0 {
491 rate_str.yellow()
492 } else {
493 rate_str.red()
494 };
495
496 println!(
497 "{:<20} {:>8} {:>8} {:>8} {:>8}",
498 "Total:".bold(),
499 total_passed.to_string().green(),
500 total_failed.to_string().red(),
501 grand_total,
502 rate_colored
503 );
504
505 let failed_checks: Vec<_> =
507 self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
508
509 if !failed_checks.is_empty() {
510 println!();
511 println!("{}", "Failed Checks:".red().bold());
512 let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
513 sorted_failures.sort_by_key(|(name, _)| (*name).clone());
514 for (name, (passes, fails)) in sorted_failures {
515 println!(
516 " {} ({} passed, {} failed)",
517 name.red(),
518 passes.to_string().green(),
519 fails.to_string().red()
520 );
521
522 for detail in &self.failure_details {
524 if detail.check == *name {
525 println!(
526 " {} {} {}",
527 "→".bright_black(),
528 detail.request.method.yellow(),
529 detail.request.url.bright_black()
530 );
531 println!(
532 " Expected: {} Actual status: {}",
533 detail.expected.yellow(),
534 detail.response.status.to_string().red()
535 );
536 if !detail.response.body.is_empty() {
537 let body_preview = if detail.response.body.len() > 200 {
538 format!("{}...", &detail.response.body[..200])
539 } else {
540 detail.response.body.clone()
541 };
542 println!(" Response body: {}", body_preview.bright_black());
543 }
544 if !detail.schema_violations.is_empty() {
546 println!(
547 " {} ({} violation{}):",
548 "Schema violations".red(),
549 detail.schema_violations.len(),
550 if detail.schema_violations.len() == 1 {
551 ""
552 } else {
553 "s"
554 }
555 );
556 for violation in &detail.schema_violations {
557 println!(
558 " {} {}: {} (expected: {}, actual: {})",
559 "·".bright_black(),
560 violation.field_path.yellow(),
561 violation.violation_type.red(),
562 violation.expected.bright_black(),
563 violation.actual.bright_black()
564 );
565 }
566 }
567 }
568 }
569 }
570
571 if !all_operations {
572 println!();
573 println!(
574 "{}",
575 "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
576 .yellow()
577 );
578 }
579
580 if !self.failure_details.is_empty() {
581 println!();
582 println!(
583 "{}",
584 "Full failure details saved to conformance-report.json (see failure_details array)."
585 .bright_black()
586 );
587 }
588
589 let rate_limited_count =
591 self.failure_details.iter().filter(|d| d.response.status == 429).count();
592 if rate_limited_count > 0 {
593 println!();
594 println!(
595 "{}",
596 format!(
597 "Warning: {} check(s) received HTTP 429 (Too Many Requests) from the target API.",
598 rate_limited_count
599 )
600 .yellow()
601 .bold()
602 );
603 println!(
604 "{}",
605 "The target server is rate-limiting requests. Use --conformance-delay <ms> to add a pause"
606 .yellow()
607 );
608 println!(
609 "{}",
610 "between requests (e.g., --conformance-delay 200 for 200ms between each check)."
611 .yellow()
612 );
613 }
614
615 println!();
617 println!(
618 "{}",
619 "Note: \"Failed\" = the API response did not match the expected conformance check"
620 .bright_black()
621 );
622 println!(
623 "{}",
624 "(e.g., wrong status code, missing field). These are NOT infrastructure errors."
625 .bright_black()
626 );
627 }
628
629 self.print_owasp_coverage();
631
632 if !empty_categories.is_empty() {
634 println!();
635 println!("{}", "Coverage Tips".bold());
636 println!("{}", "-".repeat(64));
637 for cat in &empty_categories {
638 if *cat == "Custom" {
639 continue;
640 }
641 println!(
642 " {} {}: {}",
643 "->".bright_cyan(),
644 cat,
645 ConformanceFeature::category_hint(cat).bright_black()
646 );
647 }
648 println!();
649 println!(
650 "{}",
651 "Use --conformance-custom <file.yaml> to add custom checks for any category."
652 .bright_black()
653 );
654 }
655
656 println!();
657 }
658
659 pub fn owasp_coverage_data(&self) -> Vec<OwaspCoverageEntry> {
665 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
668 let check_name = feature.check_name();
669
670 if let Some((passes, fails)) = self.check_results.get(check_name) {
672 let passed = *fails == 0 && *passes > 0;
673 feature_status
674 .entry(check_name)
675 .and_modify(|prev| *prev = *prev && passed)
676 .or_insert(passed);
677 } else {
678 let prefix = format!("{}:", check_name);
680 for (name, (passes, fails)) in &self.check_results {
681 if name.starts_with(&prefix) {
682 let passed = *fails == 0 && *passes > 0;
683 feature_status
684 .entry(check_name)
685 .and_modify(|prev| *prev = *prev && passed)
686 .or_insert(passed);
687 }
688 }
689 }
690 }
691
692 let mut entries = Vec::new();
693 for category in OwaspCategory::all() {
694 let id = category.identifier();
695 let name = category.short_name();
696
697 let mut tested = false;
698 let mut all_passed = true;
699 let mut via_categories: HashSet<&str> = HashSet::new();
700
701 for feature in ConformanceFeature::all() {
702 if !feature.related_owasp().contains(&id) {
703 continue;
704 }
705 if let Some(&passed) = feature_status.get(feature.check_name()) {
706 tested = true;
707 if !passed {
708 all_passed = false;
709 }
710 via_categories.insert(feature.category());
711 }
712 }
713
714 let mut cats: Vec<String> = via_categories.into_iter().map(String::from).collect();
715 cats.sort();
716
717 entries.push(OwaspCoverageEntry {
718 id: id.to_string(),
719 name: name.to_string(),
720 tested,
721 all_passed: tested && all_passed,
722 via_categories: cats,
723 });
724 }
725
726 entries
727 }
728
729 fn print_owasp_coverage(&self) {
731 println!();
732 println!("{}", "OWASP API Security Top 10 Coverage".bold());
733 println!("{}", "=".repeat(64).bright_green());
734
735 let entries = self.owasp_coverage_data();
736 for entry in &entries {
737 let (status, via) = if !entry.tested {
738 ("-".bright_black(), String::new())
739 } else {
740 let via_str = format!(" (via {})", entry.via_categories.join(", "));
741 if entry.all_passed {
742 ("✓".green(), via_str)
743 } else {
744 ("⚠".yellow(), format!("{} — has failures", via_str))
745 }
746 };
747
748 println!(" {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
749 }
750
751 let untested: Vec<&OwaspCoverageEntry> = entries.iter().filter(|e| !e.tested).collect();
759 if !untested.is_empty() {
760 println!();
761 println!(
762 "{}",
763 " Untested OWASP categories — add the listed --conformance-categories to exercise:".bright_black()
764 );
765 for entry in untested {
766 let suggestion = suggest_conformance_category_for_owasp(&entry.id);
767 println!(
768 " {} {:<40} {}",
769 entry.id.bright_black(),
770 entry.name.bright_black(),
771 suggestion.bright_black()
772 );
773 }
774 }
775 }
776
777 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
779 &self.check_results
780 }
781
782 pub fn overall_rate(&self) -> f64 {
784 let categories = self.by_category();
785 let total_passed: usize = categories.values().map(|r| r.passed).sum();
786 let total: usize = categories.values().map(|r| r.total()).sum();
787 if total == 0 {
788 0.0
789 } else {
790 (total_passed as f64 / total as f64) * 100.0
791 }
792 }
793}
794
795fn suggest_conformance_category_for_owasp(owasp_id: &str) -> &'static str {
799 match owasp_id {
800 "API1:2023" => "add `parameters` (path-param probes)",
801 "API2:2023" => "add `security` (auth probes)",
802 "API3:2023" => "add `constraints` (required/property checks)",
803 "API4:2023" => "add `request-bodies` or `constraints` (min/max/pattern/enum)",
804 "API5:2023" => "add `http-methods` (method-by-method coverage)",
805 "API6:2023" => "no single category — requires custom scenario flows",
806 "API7:2023" => "no built-in coverage — requires URL-injection custom checks",
807 "API8:2023" => "add any of `parameters` / `request-bodies` / `schema-types` / `string-formats` / `composition` / `response-codes` / `content-types` / `response-validation`",
808 "API9:2023" => "add `response-codes` or `http-methods`",
809 "API10:2023" => "add `response-validation`",
810 _ => "no mapping",
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 #[test]
826 fn custom_category_sums_per_check_executions_not_unique_names() {
827 let mut check_results = HashMap::new();
828 check_results.insert("custom:get".to_string(), (3u64, 0u64));
830 check_results.insert("custom:post".to_string(), (48u64, 0u64));
832 check_results.insert("custom:get:header:X-Trace".to_string(), (3u64, 0u64));
836 let report = ConformanceReport::from_results(check_results, Vec::new());
837 let by_cat = report.by_category();
838 let custom = by_cat.get("Custom").expect("Custom category present");
839 assert_eq!(custom.passed, 51, "primary check passes summed (3 + 48)");
840 assert_eq!(custom.failed, 0);
841 }
842
843 #[test]
845 fn primary_custom_check_classifier_skips_header_and_body_sub_checks() {
846 assert!(is_primary_custom_check("custom:get"));
847 assert!(is_primary_custom_check("custom:do-work-parallel"));
848 assert!(!is_primary_custom_check("custom:get:header:X-Trace"));
849 assert!(!is_primary_custom_check("custom:get:body:user_id:string"));
850 assert!(!is_primary_custom_check("not:custom"));
851 }
852
853 #[test]
857 fn suggest_conformance_category_returns_a_hint_for_every_owasp() {
858 for category in OwaspCategory::all() {
859 let suggestion = suggest_conformance_category_for_owasp(category.identifier());
860 assert!(
861 !suggestion.is_empty() && suggestion != "no mapping",
862 "no suggestion for {} ({})",
863 category.identifier(),
864 category.short_name()
865 );
866 }
867 }
868
869 #[test]
870 fn test_parse_conformance_report() {
871 let json = r#"{
872 "checks": {
873 "param:path:string": { "passes": 1, "fails": 0 },
874 "param:path:integer": { "passes": 1, "fails": 0 },
875 "body:json": { "passes": 0, "fails": 1 },
876 "method:GET": { "passes": 1, "fails": 0 }
877 },
878 "overall": { "overall_pass_rate": 0.75 }
879 }"#;
880
881 let report = ConformanceReport::from_json(json).unwrap();
882 let categories = report.by_category();
883
884 let params = categories.get("Parameters").unwrap();
885 assert_eq!(params.passed, 2);
886
887 let bodies = categories.get("Request Bodies").unwrap();
888 assert_eq!(bodies.failed, 1);
889 }
890
891 #[test]
892 fn test_empty_report() {
893 let json = r#"{ "checks": {} }"#;
894 let report = ConformanceReport::from_json(json).unwrap();
895 assert_eq!(report.overall_rate(), 0.0);
896 }
897
898 #[test]
899 fn test_owasp_coverage_with_failures() {
900 let json = r#"{
904 "checks": {
905 "response:404": { "passes": 0, "fails": 1 },
906 "body:json": { "passes": 1, "fails": 0 },
907 "method:GET": { "passes": 1, "fails": 0 }
908 },
909 "overall": {}
910 }"#;
911
912 let report = ConformanceReport::from_json(json).unwrap();
913 report.print_report_with_options(false);
915 }
916}