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
118fn extract_custom_base_name(check_name: &str) -> String {
122 let after_prefix = &check_name[7..];
124 if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
125 check_name[..7 + pos].to_string()
126 } else {
127 check_name.to_string()
128 }
129}
130
131pub struct ConformanceReport {
133 check_results: HashMap<String, (u64, u64)>,
135 failure_details: Vec<FailureDetail>,
137}
138
139impl ConformanceReport {
140 pub fn from_results(
143 check_results: HashMap<String, (u64, u64)>,
144 failure_details: Vec<FailureDetail>,
145 ) -> Self {
146 Self {
147 check_results,
148 failure_details,
149 }
150 }
151
152 pub fn to_json(&self) -> serde_json::Value {
157 let mut checks = serde_json::Map::new();
158 for (name, (passes, fails)) in &self.check_results {
159 checks.insert(
160 name.clone(),
161 serde_json::json!({
162 "passes": passes,
163 "fails": fails,
164 }),
165 );
166 }
167
168 let by_cat = self.by_category();
170 let mut categories_json = serde_json::Map::new();
171 for (cat_name, cat_result) in &by_cat {
172 categories_json.insert(
173 (*cat_name).to_string(),
174 serde_json::json!({
175 "passed": cat_result.passed,
176 "total": cat_result.total(),
177 "rate": cat_result.rate(),
178 }),
179 );
180 }
181
182 let total_passed: usize = by_cat.values().map(|r| r.passed).sum();
184 let total: usize = by_cat.values().map(|r| r.total()).sum();
185 let overall_rate = if total == 0 {
186 0.0
187 } else {
188 (total_passed as f64 / total as f64) * 100.0
189 };
190
191 let failures: Vec<serde_json::Value> = self
193 .failure_details
194 .iter()
195 .map(|d| {
196 let category = Self::category_for_check(&d.check);
197 serde_json::json!({
198 "check_name": d.check,
199 "category": category,
200 "expected": d.expected,
201 "actual": format!("status {}", d.response.status),
202 "details": format!("{} {}", d.request.method, d.request.url),
203 })
204 })
205 .collect();
206
207 let mut result = serde_json::json!({
208 "checks": checks,
209 "summary": {
210 "total_checks": total,
211 "passed": total_passed,
212 "failed": total - total_passed,
213 "overall_rate": overall_rate,
214 },
215 "categories": categories_json,
216 "failures": failures,
217 });
218
219 if !self.failure_details.is_empty() {
221 result["failure_details"] = serde_json::to_value(&self.failure_details)
222 .unwrap_or(serde_json::Value::Array(Vec::new()));
223 }
224 result
225 }
226
227 fn category_for_check(check_name: &str) -> &'static str {
229 let prefix = check_name.split(':').next().unwrap_or("");
230 match prefix {
231 "param" => "Parameters",
232 "body" => "Request Bodies",
233 "response" => "Response Codes",
234 "schema" => "Schema Types",
235 "compose" => "Composition",
236 "format" => "String Formats",
237 "constraint" => "Constraints",
238 "security" => "Security",
239 "method" => "HTTP Methods",
240 "content" => "Content Types",
241 "validation" | "response_validation" => "Response Validation",
242 "custom" => "Custom",
243 _ => "Other",
244 }
245 }
246
247 pub fn failure_details(&self) -> &[FailureDetail] {
249 &self.failure_details
250 }
251
252 pub fn from_file(path: &Path) -> Result<Self> {
256 let content = std::fs::read_to_string(path)
257 .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
258 let mut report = Self::from_json(&content)?;
259
260 if let Some(parent) = path.parent() {
262 let details_path = parent.join("conformance-failure-details.json");
263 if details_path.exists() {
264 if let Ok(details_json) = std::fs::read_to_string(&details_path) {
265 if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
266 report.failure_details = details;
267 }
268 }
269 }
270 }
271
272 Ok(report)
273 }
274
275 pub fn from_json(json_str: &str) -> Result<Self> {
277 let json: serde_json::Value = serde_json::from_str(json_str)
278 .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
279
280 let mut check_results = HashMap::new();
281
282 if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
283 for (name, result) in checks {
284 let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
285 let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
286 check_results.insert(name.clone(), (passes, fails));
287 }
288 }
289
290 Ok(Self {
291 check_results,
292 failure_details: Vec::new(),
293 })
294 }
295
296 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
301 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
302
303 for cat in ConformanceFeature::categories() {
305 categories.insert(cat, CategoryResult::default());
306 }
307
308 for feature in ConformanceFeature::all() {
312 let check_name = feature.check_name();
313 let category = feature.category();
314
315 let entry = categories.entry(category).or_default();
316
317 if let Some((passes, fails)) = self.check_results.get(check_name) {
319 if *fails == 0 && *passes > 0 {
320 entry.passed += 1;
321 } else {
322 entry.failed += 1;
323 }
324 } else {
325 let prefix = format!("{}:", check_name);
327 for (name, (passes, fails)) in &self.check_results {
328 if name.starts_with(&prefix) {
329 if *fails == 0 && *passes > 0 {
330 entry.passed += 1;
331 } else {
332 entry.failed += 1;
333 }
334 }
335 }
336 }
338 }
339
340 let custom_entry = categories.entry("Custom").or_default();
342 let mut counted_custom: HashSet<String> = HashSet::new();
344 for (name, (passes, fails)) in &self.check_results {
345 if name.starts_with("custom:") {
346 let base_name = extract_custom_base_name(name);
351 if counted_custom.insert(base_name) {
352 if *fails == 0 && *passes > 0 {
353 custom_entry.passed += 1;
354 } else {
355 custom_entry.failed += 1;
356 }
357 }
358 }
359 }
360
361 categories
362 }
363
364 pub fn print_report(&self) {
366 self.print_report_with_options(false);
367 }
368
369 pub fn print_report_with_options(&self, all_operations: bool) {
371 let categories = self.by_category();
372
373 let total_possible = ConformanceFeature::all().len();
375 let active_cats: usize = ConformanceFeature::categories()
376 .iter()
377 .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
378 .count();
379 let detected: usize =
380 categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
381
382 println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
383 println!("{}", "=".repeat(64).bright_green());
384
385 println!(
386 "{}",
387 format!(
388 "Spec Analysis: {} of {} features detected across {} categories",
389 detected, total_possible, active_cats
390 )
391 .bright_cyan()
392 );
393 println!();
394
395 println!(
396 "{:<20} {:>8} {:>8} {:>8} {:>8}",
397 "Category".bold(),
398 "Passed".green().bold(),
399 "Failed".red().bold(),
400 "Total".bold(),
401 "Rate".bold()
402 );
403 println!("{}", "-".repeat(64));
404
405 let mut total_passed = 0usize;
406 let mut total_failed = 0usize;
407 let mut empty_categories: Vec<&str> = Vec::new();
408
409 let all_cat_names: Vec<&str> = {
411 let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
412 if categories.get("Custom").is_some_and(|r| r.total() > 0) {
413 cats.push("Custom");
414 }
415 cats
416 };
417
418 for cat_name in &all_cat_names {
419 if let Some(result) = categories.get(cat_name) {
420 let total = result.total();
421 if total == 0 {
422 println!(
424 "{:<20} {:>8} {:>8} {:>8} {:>8}",
425 cat_name.bright_black(),
426 "-".bright_black(),
427 "-".bright_black(),
428 "-".bright_black(),
429 "not in spec".bright_black()
430 );
431 empty_categories.push(cat_name);
432 continue;
433 }
434 total_passed += result.passed;
435 total_failed += result.failed;
436
437 let rate_str = format!("{:.0}%", result.rate());
438 let rate_colored = if result.rate() >= 100.0 {
439 rate_str.green()
440 } else if result.rate() >= 80.0 {
441 rate_str.yellow()
442 } else {
443 rate_str.red()
444 };
445
446 println!(
447 "{:<20} {:>8} {:>8} {:>8} {:>8}",
448 cat_name,
449 result.passed.to_string().green(),
450 result.failed.to_string().red(),
451 total,
452 rate_colored
453 );
454 }
455 }
456
457 println!("{}", "=".repeat(64).bright_green());
458
459 let grand_total = total_passed + total_failed;
460 let overall_rate = if grand_total > 0 {
461 (total_passed as f64 / grand_total as f64) * 100.0
462 } else {
463 0.0
464 };
465 let rate_str = format!("{:.0}%", overall_rate);
466 let rate_colored = if overall_rate >= 100.0 {
467 rate_str.green()
468 } else if overall_rate >= 80.0 {
469 rate_str.yellow()
470 } else {
471 rate_str.red()
472 };
473
474 println!(
475 "{:<20} {:>8} {:>8} {:>8} {:>8}",
476 "Total:".bold(),
477 total_passed.to_string().green(),
478 total_failed.to_string().red(),
479 grand_total,
480 rate_colored
481 );
482
483 let failed_checks: Vec<_> =
485 self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
486
487 if !failed_checks.is_empty() {
488 println!();
489 println!("{}", "Failed Checks:".red().bold());
490 let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
491 sorted_failures.sort_by_key(|(name, _)| (*name).clone());
492 for (name, (passes, fails)) in sorted_failures {
493 println!(
494 " {} ({} passed, {} failed)",
495 name.red(),
496 passes.to_string().green(),
497 fails.to_string().red()
498 );
499
500 for detail in &self.failure_details {
502 if detail.check == *name {
503 println!(
504 " {} {} {}",
505 "→".bright_black(),
506 detail.request.method.yellow(),
507 detail.request.url.bright_black()
508 );
509 println!(
510 " Expected: {} Actual status: {}",
511 detail.expected.yellow(),
512 detail.response.status.to_string().red()
513 );
514 if !detail.response.body.is_empty() {
515 let body_preview = if detail.response.body.len() > 200 {
516 format!("{}...", &detail.response.body[..200])
517 } else {
518 detail.response.body.clone()
519 };
520 println!(" Response body: {}", body_preview.bright_black());
521 }
522 if !detail.schema_violations.is_empty() {
524 println!(
525 " {} ({} violation{}):",
526 "Schema violations".red(),
527 detail.schema_violations.len(),
528 if detail.schema_violations.len() == 1 {
529 ""
530 } else {
531 "s"
532 }
533 );
534 for violation in &detail.schema_violations {
535 println!(
536 " {} {}: {} (expected: {}, actual: {})",
537 "·".bright_black(),
538 violation.field_path.yellow(),
539 violation.violation_type.red(),
540 violation.expected.bright_black(),
541 violation.actual.bright_black()
542 );
543 }
544 }
545 }
546 }
547 }
548
549 if !all_operations {
550 println!();
551 println!(
552 "{}",
553 "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
554 .yellow()
555 );
556 }
557
558 if !self.failure_details.is_empty() {
559 println!();
560 println!(
561 "{}",
562 "Full failure details saved to conformance-report.json (see failure_details array)."
563 .bright_black()
564 );
565 }
566
567 let rate_limited_count =
569 self.failure_details.iter().filter(|d| d.response.status == 429).count();
570 if rate_limited_count > 0 {
571 println!();
572 println!(
573 "{}",
574 format!(
575 "Warning: {} check(s) received HTTP 429 (Too Many Requests) from the target API.",
576 rate_limited_count
577 )
578 .yellow()
579 .bold()
580 );
581 println!(
582 "{}",
583 "The target server is rate-limiting requests. Use --conformance-delay <ms> to add a pause"
584 .yellow()
585 );
586 println!(
587 "{}",
588 "between requests (e.g., --conformance-delay 200 for 200ms between each check)."
589 .yellow()
590 );
591 }
592
593 println!();
595 println!(
596 "{}",
597 "Note: \"Failed\" = the API response did not match the expected conformance check"
598 .bright_black()
599 );
600 println!(
601 "{}",
602 "(e.g., wrong status code, missing field). These are NOT infrastructure errors."
603 .bright_black()
604 );
605 }
606
607 self.print_owasp_coverage();
609
610 if !empty_categories.is_empty() {
612 println!();
613 println!("{}", "Coverage Tips".bold());
614 println!("{}", "-".repeat(64));
615 for cat in &empty_categories {
616 if *cat == "Custom" {
617 continue;
618 }
619 println!(
620 " {} {}: {}",
621 "->".bright_cyan(),
622 cat,
623 ConformanceFeature::category_hint(cat).bright_black()
624 );
625 }
626 println!();
627 println!(
628 "{}",
629 "Use --conformance-custom <file.yaml> to add custom checks for any category."
630 .bright_black()
631 );
632 }
633
634 println!();
635 }
636
637 pub fn owasp_coverage_data(&self) -> Vec<OwaspCoverageEntry> {
643 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
646 let check_name = feature.check_name();
647
648 if let Some((passes, fails)) = self.check_results.get(check_name) {
650 let passed = *fails == 0 && *passes > 0;
651 feature_status
652 .entry(check_name)
653 .and_modify(|prev| *prev = *prev && passed)
654 .or_insert(passed);
655 } else {
656 let prefix = format!("{}:", check_name);
658 for (name, (passes, fails)) in &self.check_results {
659 if name.starts_with(&prefix) {
660 let passed = *fails == 0 && *passes > 0;
661 feature_status
662 .entry(check_name)
663 .and_modify(|prev| *prev = *prev && passed)
664 .or_insert(passed);
665 }
666 }
667 }
668 }
669
670 let mut entries = Vec::new();
671 for category in OwaspCategory::all() {
672 let id = category.identifier();
673 let name = category.short_name();
674
675 let mut tested = false;
676 let mut all_passed = true;
677 let mut via_categories: HashSet<&str> = HashSet::new();
678
679 for feature in ConformanceFeature::all() {
680 if !feature.related_owasp().contains(&id) {
681 continue;
682 }
683 if let Some(&passed) = feature_status.get(feature.check_name()) {
684 tested = true;
685 if !passed {
686 all_passed = false;
687 }
688 via_categories.insert(feature.category());
689 }
690 }
691
692 let mut cats: Vec<String> = via_categories.into_iter().map(String::from).collect();
693 cats.sort();
694
695 entries.push(OwaspCoverageEntry {
696 id: id.to_string(),
697 name: name.to_string(),
698 tested,
699 all_passed: tested && all_passed,
700 via_categories: cats,
701 });
702 }
703
704 entries
705 }
706
707 fn print_owasp_coverage(&self) {
709 println!();
710 println!("{}", "OWASP API Security Top 10 Coverage".bold());
711 println!("{}", "=".repeat(64).bright_green());
712
713 for entry in self.owasp_coverage_data() {
714 let (status, via) = if !entry.tested {
715 ("-".bright_black(), String::new())
716 } else {
717 let via_str = format!(" (via {})", entry.via_categories.join(", "));
718 if entry.all_passed {
719 ("✓".green(), via_str)
720 } else {
721 ("⚠".yellow(), format!("{} — has failures", via_str))
722 }
723 };
724
725 println!(" {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
726 }
727 }
728
729 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
731 &self.check_results
732 }
733
734 pub fn overall_rate(&self) -> f64 {
736 let categories = self.by_category();
737 let total_passed: usize = categories.values().map(|r| r.passed).sum();
738 let total: usize = categories.values().map(|r| r.total()).sum();
739 if total == 0 {
740 0.0
741 } else {
742 (total_passed as f64 / total as f64) * 100.0
743 }
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
752 fn test_parse_conformance_report() {
753 let json = r#"{
754 "checks": {
755 "param:path:string": { "passes": 1, "fails": 0 },
756 "param:path:integer": { "passes": 1, "fails": 0 },
757 "body:json": { "passes": 0, "fails": 1 },
758 "method:GET": { "passes": 1, "fails": 0 }
759 },
760 "overall": { "overall_pass_rate": 0.75 }
761 }"#;
762
763 let report = ConformanceReport::from_json(json).unwrap();
764 let categories = report.by_category();
765
766 let params = categories.get("Parameters").unwrap();
767 assert_eq!(params.passed, 2);
768
769 let bodies = categories.get("Request Bodies").unwrap();
770 assert_eq!(bodies.failed, 1);
771 }
772
773 #[test]
774 fn test_empty_report() {
775 let json = r#"{ "checks": {} }"#;
776 let report = ConformanceReport::from_json(json).unwrap();
777 assert_eq!(report.overall_rate(), 0.0);
778 }
779
780 #[test]
781 fn test_owasp_coverage_with_failures() {
782 let json = r#"{
786 "checks": {
787 "response:404": { "passes": 0, "fails": 1 },
788 "body:json": { "passes": 1, "fails": 0 },
789 "method:GET": { "passes": 1, "fails": 0 }
790 },
791 "overall": {}
792 }"#;
793
794 let report = ConformanceReport::from_json(json).unwrap();
795 report.print_report_with_options(false);
797 }
798}