use std::collections::BTreeMap; use serde_json::Value; use crate::rest::model::{ CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition, CoverageEntry, ImportSourcePreview, NORMALIZER_VERSION, NormalizedIr, NormalizedOperation, NormalizedPath, NormalizedReference, NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION, SourceLocation, }; use super::ImportParseError; pub(super) use super::normalize_schema::{has_reference_schema, typed_schema}; pub(super) fn validate_coverage(ir: &NormalizedIr) -> Result<(), ImportParseError> { if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION { return Err(ImportParseError::InvalidDocument); } let mut source_nodes = BTreeMap::new(); index_source_tree(&ir.source_tree, "", &mut source_nodes)?; let version = validate_source_contract(ir, &source_nodes)?; let mut expected = BTreeMap::::new(); for node in source_nodes.values() { insert_expected( &mut expected, node.construct_id.clone(), node.location.clone(), CoverageDisposition::Mapped, )?; } insert_real_expected( &mut expected, &source_nodes, "document".to_owned(), SourceLocation { pointer: String::new(), }, CoverageDisposition::Mapped, )?; for (index, location) in server_locations(ir, &source_nodes)?.into_iter().enumerate() { insert_real_expected( &mut expected, &source_nodes, format!("{version}:server:{index}"), location, CoverageDisposition::Mapped, )?; } let expected_references = references_from_source_tree(&ir.source_tree); if ir.unresolved_references != expected_references { return Err(ImportParseError::InvalidDocument); } for reference in &ir.unresolved_references { insert_real_expected( &mut expected, &source_nodes, reference.construct_id.clone(), reference.location.clone(), CoverageDisposition::Mapped, )?; } let expected_paths = paths_from_operations(&ir.operations, &version); if ir.paths != expected_paths { return Err(ImportParseError::InvalidDocument); } for path in &ir.paths { insert_real_expected( &mut expected, &source_nodes, path.construct_id.clone(), path.location.clone(), CoverageDisposition::Mapped, )?; } validate_operation_order(&ir.operations)?; for operation in &ir.operations { collect_operation_expectations(operation, &version, &source_nodes, &mut expected)?; } collect_finding_only_expectations(&version, ir, &source_nodes, &mut expected)?; let findings = ir .findings .iter() .chain( ir.operations .iter() .flat_map(|operation| &operation.findings), ) .collect::>(); for finding in &findings { let Some(target) = expected.get(&finding.construct_id) else { return Err(ImportParseError::InvalidDocument); }; if target.location != finding.location { return Err(ImportParseError::InvalidDocument); } } if expected.iter().any(|(construct_id, target)| { target.disposition == CoverageDisposition::Finding && !findings.iter().any(|finding| { finding.construct_id == *construct_id && finding.location == target.location }) }) { return Err(ImportParseError::InvalidDocument); } let mut actual = BTreeMap::new(); for entry in &ir.coverage { if actual .insert( entry.construct_id.clone(), ExpectedCoverage { location: entry.location.clone(), disposition: entry.disposition.clone(), }, ) .is_some() { return Err(ImportParseError::InvalidDocument); } } if actual != expected { return Err(ImportParseError::InvalidDocument); } Ok(()) } #[derive(Clone, Debug, PartialEq, Eq)] struct ExpectedCoverage { location: SourceLocation, disposition: CoverageDisposition, } fn insert_expected( expected: &mut BTreeMap, construct_id: String, location: SourceLocation, disposition: CoverageDisposition, ) -> Result<(), ImportParseError> { let value = ExpectedCoverage { location, disposition, }; if expected .get(&construct_id) .is_some_and(|existing| existing != &value) { return Err(ImportParseError::InvalidDocument); } expected.entry(construct_id).or_insert(value); Ok(()) } fn insert_real_expected( expected: &mut BTreeMap, source_nodes: &BTreeMap, construct_id: String, location: SourceLocation, disposition: CoverageDisposition, ) -> Result<(), ImportParseError> { if !source_nodes.contains_key(&location.pointer) { return Err(ImportParseError::InvalidDocument); } insert_expected(expected, construct_id, location, disposition) } fn index_source_tree<'a>( node: &'a CanonicalSourceNode, expected_pointer: &str, nodes: &mut BTreeMap, ) -> Result<(), ImportParseError> { if node.location.pointer != expected_pointer || node.construct_id != format!("source:{expected_pointer}") || nodes.insert(expected_pointer.to_owned(), node).is_some() { return Err(ImportParseError::InvalidDocument); } match &node.value { CanonicalSourceValue::Array(items) => { for (index, child) in items.iter().enumerate() { index_source_tree(child, &format!("{expected_pointer}/{index}"), nodes)?; } } CanonicalSourceValue::Object(items) => { for (key, child) in items { index_source_tree( child, &format!("{expected_pointer}/{}", escape_pointer(key)), nodes, )?; } } _ => {} } Ok(()) } fn validate_source_contract( ir: &NormalizedIr, nodes: &BTreeMap, ) -> Result { let version = ir .source .version .as_deref() .ok_or(ImportParseError::InvalidDocument)?; let valid = match ir.source.format.as_str() { "openapi" => string_at(nodes, "/openapi") == Some(version) && supported_oas(version), "swagger" => version == "2.0" && string_at(nodes, "/swagger") == Some("2.0"), _ => false, }; if !valid || ir.metadata.title != ir.source.title || ir.source.title != string_at(nodes, "/info/title").unwrap_or("Imported API") || ir.metadata.version != string_at(nodes, "/info/version").map(ToOwned::to_owned) || ir.metadata.description != string_at(nodes, "/info/description").map(ToOwned::to_owned) || ir.base_path_candidates != string_at(nodes, "/basePath") .map(|value| vec![value.to_owned()]) .unwrap_or_default() { return Err(ImportParseError::InvalidDocument); } Ok(version.to_owned()) } fn supported_oas(version: &str) -> bool { ["3.0.", "3.1."].into_iter().any(|prefix| { version.strip_prefix(prefix).is_some_and(|patch| { !patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit()) }) }) } fn string_at<'a>( nodes: &'a BTreeMap, pointer: &str, ) -> Option<&'a str> { match &nodes.get(pointer)?.value { CanonicalSourceValue::String(value) => Some(value), _ => None, } } fn server_locations( ir: &NormalizedIr, nodes: &BTreeMap, ) -> Result, ImportParseError> { server_locations_for_source(&ir.source, nodes) } fn server_locations_for_source( source: &ImportSourcePreview, nodes: &BTreeMap, ) -> Result, ImportParseError> { let (urls, locations) = if source.format == "openapi" { openapi_servers(nodes) } else { swagger_servers(nodes) }; if urls != source.servers { return Err(ImportParseError::InvalidDocument); } Ok(locations) } pub(super) fn server_coverage( source: &ImportSourcePreview, source_tree: &CanonicalSourceNode, version: &str, ) -> Result, ImportParseError> { let mut nodes = BTreeMap::new(); index_source_tree(source_tree, "", &mut nodes)?; Ok(server_locations_for_source(source, &nodes)? .into_iter() .enumerate() .map(|(index, location)| CoverageEntry { construct_id: format!("{version}:server:{index}"), location, disposition: CoverageDisposition::Mapped, }) .collect()) } pub(super) fn tag_locations( root: &Value, operation_pointer: &str, tags: &[String], ) -> Result, ImportParseError> { let source_tags = root .pointer(&format!("{operation_pointer}/tags")) .and_then(Value::as_array); let locations = source_tags .into_iter() .flatten() .enumerate() .filter_map(|(index, value)| { value.as_str().map(|value| { ( value, SourceLocation { pointer: format!("{operation_pointer}/tags/{index}"), }, ) }) }) .collect::>(); if locations .iter() .map(|(value, _)| *value) .ne(tags.iter().map(String::as_str)) { return Err(ImportParseError::InvalidDocument); } Ok(locations .into_iter() .map(|(_, location)| location) .collect()) } fn openapi_servers( nodes: &BTreeMap, ) -> (Vec, Vec) { let Some(CanonicalSourceNode { value: CanonicalSourceValue::Array(items), .. }) = nodes.get("/servers").copied() else { return (Vec::new(), Vec::new()); }; items .iter() .filter_map(|item| { let CanonicalSourceValue::Object(fields) = &item.value else { return None; }; let CanonicalSourceValue::String(url) = &fields.get("url")?.value else { return None; }; Some((url.trim_end_matches('/').to_owned(), item.location.clone())) }) .unzip() } fn swagger_servers( nodes: &BTreeMap, ) -> (Vec, Vec) { let Some(host) = string_at(nodes, "/host") else { return (Vec::new(), Vec::new()); }; let base_path = string_at(nodes, "/basePath").unwrap_or(""); let schemes = nodes .get("/schemes") .and_then(|node| match &node.value { CanonicalSourceValue::Array(items) => Some( items .iter() .filter_map(|item| match &item.value { CanonicalSourceValue::String(value) => Some((value.as_str(), item)), _ => None, }) .collect::>(), ), _ => None, }) .filter(|items| !items.is_empty()); let schemes = schemes.unwrap_or_else(|| { vec![( "https", *nodes.get("/host").expect("host was checked above"), )] }); schemes .into_iter() .map(|(scheme, node)| { ( format!("{scheme}://{host}{base_path}") .trim_end_matches('/') .to_owned(), node.location.clone(), ) }) .unzip() } fn validate_operation_order(operations: &[NormalizedOperation]) -> Result<(), ImportParseError> { if operations .windows(2) .any(|pair| operation_sort_key(&pair[0]) >= operation_sort_key(&pair[1])) { return Err(ImportParseError::InvalidDocument); } Ok(()) } fn operation_sort_key(operation: &NormalizedOperation) -> (&str, u8, &str) { ( &operation.path, method_rank(operation.method), &operation.location.pointer, ) } fn method_rank(method: crank_core::HttpMethod) -> u8 { match method { crank_core::HttpMethod::Get => 0, crank_core::HttpMethod::Post => 1, crank_core::HttpMethod::Put => 2, crank_core::HttpMethod::Patch => 3, crank_core::HttpMethod::Delete => 4, } } fn method_name(method: crank_core::HttpMethod) -> &'static str { match method { crank_core::HttpMethod::Get => "get", crank_core::HttpMethod::Post => "post", crank_core::HttpMethod::Put => "put", crank_core::HttpMethod::Patch => "patch", crank_core::HttpMethod::Delete => "delete", } } fn collect_operation_expectations( operation: &NormalizedOperation, version: &str, source_nodes: &BTreeMap, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { let method = method_name(operation.method); let pointer = format!("/paths/{}/{method}", escape_pointer(&operation.path)); let stable_id = format!("{version}:{method}:{pointer}"); if operation.location.pointer != pointer || operation.stable_id != stable_id || operation.key != format!("{} {}", method.to_ascii_uppercase(), operation.path) { return Err(ImportParseError::InvalidDocument); } insert_real_expected( expected, source_nodes, stable_id.clone(), operation.location.clone(), CoverageDisposition::Mapped, )?; let operation_id_location = SourceLocation { pointer: format!("{pointer}/operationId"), }; let operation_id_disposition = match operation .operation_id .as_deref() .filter(|value| !value.trim().is_empty()) { Some(value) if string_at(source_nodes, &operation_id_location.pointer) == Some(value) => { CoverageDisposition::Mapped } Some(_) => return Err(ImportParseError::InvalidDocument), None => CoverageDisposition::Finding, }; if operation_id_disposition == CoverageDisposition::Mapped || source_nodes.contains_key(&operation_id_location.pointer) { insert_real_expected( expected, source_nodes, format!("{stable_id}:operation_id"), operation_id_location, operation_id_disposition, )?; } else { insert_expected( expected, format!("{stable_id}:operation_id"), operation_id_location, CoverageDisposition::Finding, )?; } let tag_locations = normalized_tag_locations(source_nodes, &pointer, &operation.tags)?; for (index, location) in tag_locations.into_iter().enumerate() { insert_real_expected( expected, source_nodes, format!("{stable_id}:tag:{index}"), location, CoverageDisposition::Mapped, )?; } for parameter in &operation.parameters { if parameter.construct_id != format!( "parameter:{}", escape_pointer(¶meter.source_location.pointer) ) { return Err(ImportParseError::InvalidDocument); } insert_real_expected( expected, source_nodes, parameter.construct_id.clone(), parameter.source_location.clone(), CoverageDisposition::Mapped, )?; if let Some(schema) = ¶meter.schema { collect_schema_expectations(schema, source_nodes, expected)?; } } if let Some(schema) = &operation.request_body_schema { collect_schema_expectations(schema, source_nodes, expected)?; } if let Some(schema) = &operation.response_schema { collect_schema_expectations(schema, source_nodes, expected)?; } Ok(()) } fn normalized_tag_locations( nodes: &BTreeMap, operation_pointer: &str, tags: &[String], ) -> Result, ImportParseError> { let tags_pointer = format!("{operation_pointer}/tags"); let source_tags = nodes.get(&tags_pointer).and_then(|node| match &node.value { CanonicalSourceValue::Array(items) => Some(items), _ => None, }); let locations = source_tags .into_iter() .flatten() .filter_map(|node| match &node.value { CanonicalSourceValue::String(value) => Some((value, node.location.clone())), _ => None, }) .collect::>(); if locations.iter().map(|(value, _)| *value).ne(tags.iter()) { return Err(ImportParseError::InvalidDocument); } Ok(locations .into_iter() .map(|(_, location)| location) .collect()) } fn collect_schema_expectations( schema: &NormalizedSchema, source_nodes: &BTreeMap, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { if schema.construct_id != format!("schema:{}", escape_pointer(&schema.location.pointer)) { return Err(ImportParseError::InvalidDocument); } insert_real_expected( expected, source_nodes, schema.construct_id.clone(), schema.location.clone(), CoverageDisposition::Mapped, )?; if schema.description != string_at( source_nodes, &format!("{}/description", schema.location.pointer), ) .map(ToOwned::to_owned) { return Err(ImportParseError::InvalidDocument); } match &schema.kind { NormalizedSchemaKind::Object { properties, .. } => { for child in properties.values() { collect_schema_expectations(child, source_nodes, expected)?; } } NormalizedSchemaKind::Array { items: Some(child) } => { collect_schema_expectations(child, source_nodes, expected)?; } NormalizedSchemaKind::Composition { variants, .. } => { for child in variants { collect_schema_expectations(child, source_nodes, expected)?; } } _ => {} } Ok(()) } fn collect_finding_only_expectations( version: &str, ir: &NormalizedIr, source_nodes: &BTreeMap, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { if ir.source.format == "openapi" && ir.source.servers.len() > 1 { let location = SourceLocation { pointer: "/servers".to_owned(), }; insert_real_expected( expected, source_nodes, format!("{version}:/servers"), location, CoverageDisposition::Finding, )?; } if ir.source.format == "openapi" && let Some(servers) = source_nodes.get("/servers").copied() { collect_invalid_openapi_server_findings(version, servers, expected)?; } let Some(CanonicalSourceNode { value: CanonicalSourceValue::Object(paths), .. }) = source_nodes.get("/paths").copied() else { return Err(ImportParseError::InvalidDocument); }; for path_item in paths.values() { let CanonicalSourceValue::Object(methods) = &path_item.value else { insert_finding_pointer(version, path_item, expected)?; continue; }; if ir.source.format == "openapi" && let Some(servers) = methods.get("servers") { collect_invalid_openapi_server_findings(version, servers, expected)?; } if let Some(parameters) = methods.get("parameters") { collect_dropped_parameter_findings( version, parameters, ir.source.format.as_str(), expected, )?; } for method in ["head", "options", "trace", "connect"] { if let Some(node) = methods.get(method) { insert_finding_pointer(version, node, expected)?; } } for method in ["get", "post", "put", "patch", "delete"] { if let Some(node) = methods.get(method) { match &node.value { CanonicalSourceValue::Object(operation) => { if ir.source.format == "openapi" && let Some(servers) = operation.get("servers") { collect_invalid_openapi_server_findings(version, servers, expected)?; } if let Some(tags) = operation.get("tags") { collect_invalid_tag_findings(version, tags, expected)?; } if let Some(parameters) = operation.get("parameters") { collect_dropped_parameter_findings( version, parameters, ir.source.format.as_str(), expected, )?; } } _ => insert_finding_pointer(version, node, expected)?, } } } } Ok(()) } fn collect_invalid_openapi_server_findings( version: &str, servers: &CanonicalSourceNode, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { let CanonicalSourceValue::Array(items) = &servers.value else { return Ok(()); }; for server in items { let valid = matches!( &server.value, CanonicalSourceValue::Object(fields) if matches!(fields.get("url").map(|node| &node.value), Some(CanonicalSourceValue::String(_))) ); if !valid { insert_finding_pointer(version, server, expected)?; } } Ok(()) } fn collect_invalid_tag_findings( version: &str, tags: &CanonicalSourceNode, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { let CanonicalSourceValue::Array(items) = &tags.value else { return Ok(()); }; for tag in items { if !matches!(tag.value, CanonicalSourceValue::String(_)) { insert_finding_pointer(version, tag, expected)?; } } Ok(()) } fn collect_dropped_parameter_findings( version: &str, parameters: &CanonicalSourceNode, format: &str, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { let CanonicalSourceValue::Array(items) = ¶meters.value else { return Ok(()); }; for parameter in items { let CanonicalSourceValue::Object(fields) = ¶meter.value else { insert_finding_pointer(version, parameter, expected)?; continue; }; if fields.contains_key("$ref") { insert_finding_pointer(version, parameter, expected)?; continue; } if !matches!( fields.get("name").map(|node| &node.value), Some(CanonicalSourceValue::String(_)) ) || !matches!( fields.get("in").map(|node| &node.value), Some(CanonicalSourceValue::String(_)) ) { insert_finding_pointer(version, parameter, expected)?; continue; } let Some(CanonicalSourceNode { value: CanonicalSourceValue::String(location), .. }) = fields.get("in") else { return Err(ImportParseError::InvalidDocument); }; let supported = match format { "openapi" => matches!(location.as_str(), "path" | "query" | "header"), "swagger" => matches!(location.as_str(), "path" | "query" | "header" | "body"), _ => false, }; if !supported { insert_finding_pointer(version, parameter, expected)?; } } Ok(()) } fn insert_finding_pointer( version: &str, node: &CanonicalSourceNode, expected: &mut BTreeMap, ) -> Result<(), ImportParseError> { insert_expected( expected, format!("{version}:{}", node.location.pointer), node.location.clone(), CoverageDisposition::Finding, ) } pub(super) fn references_from_source_tree( source_tree: &CanonicalSourceNode, ) -> Vec { let mut references = Vec::new(); collect_references(source_tree, &mut references); references.sort_by(|left, right| left.construct_id.cmp(&right.construct_id)); references } fn collect_references(node: &CanonicalSourceNode, references: &mut Vec) { match &node.value { CanonicalSourceValue::Array(items) => { for item in items { collect_references(item, references); } } CanonicalSourceValue::Object(items) => { if let Some(CanonicalSourceNode { value: CanonicalSourceValue::String(uri), location, .. }) = items.get("$ref") { references.push(NormalizedReference { construct_id: format!("reference:{}", escape_pointer(&location.pointer)), uri: uri.clone(), location: location.clone(), }); } for item in items.values() { collect_references(item, references); } } _ => {} } } pub(super) fn canonical_source_tree( value: &Value, pointer: &str, coverage: &mut Vec, ) -> CanonicalSourceNode { let id = format!("source:{pointer}"); let location = SourceLocation { pointer: pointer.to_owned(), }; coverage.push(CoverageEntry { construct_id: id.clone(), location: location.clone(), disposition: CoverageDisposition::Mapped, }); let value = match value { Value::Null => CanonicalSourceValue::Null, Value::Bool(value) => CanonicalSourceValue::Boolean(*value), Value::Number(value) => CanonicalSourceValue::Number(value.to_string()), Value::String(value) => CanonicalSourceValue::String(value.clone()), Value::Array(items) => CanonicalSourceValue::Array( items .iter() .enumerate() .map(|(index, value)| { canonical_source_tree(value, &format!("{pointer}/{index}"), coverage) }) .collect(), ), Value::Object(items) => CanonicalSourceValue::Object( items .iter() .map(|(key, value)| { ( key.clone(), canonical_source_tree( value, &format!("{pointer}/{}", escape_pointer(key)), coverage, ), ) }) .collect(), ), }; CanonicalSourceNode { construct_id: id, location, value, } } pub(super) fn paths_from_operations( operations: &[NormalizedOperation], version: &str, ) -> Vec { let mut paths = BTreeMap::>::new(); for operation in operations { paths .entry(operation.path.clone()) .or_default() .push(operation.stable_id.clone()); } paths .into_iter() .map(|(path, operation_ids)| NormalizedPath { construct_id: format!("{version}:path:{path}"), location: SourceLocation { pointer: format!("/paths/{}", escape_pointer(&path)), }, path, operation_ids, }) .collect() } pub(super) fn escape_pointer(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") }