feat(import): add deterministic OpenAPI normalized IR
This commit is contained in:
@@ -0,0 +1,887 @@
|
||||
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::<String, ExpectedCoverage>::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::<Vec<_>>();
|
||||
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<String, ExpectedCoverage>,
|
||||
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<String, ExpectedCoverage>,
|
||||
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||
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<String, &'a CanonicalSourceNode>,
|
||||
) -> 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<String, &CanonicalSourceNode>,
|
||||
) -> Result<String, ImportParseError> {
|
||||
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<String, &CanonicalSourceNode>,
|
||||
pointer: &str,
|
||||
) -> Option<&'a str> {
|
||||
match &nodes.get(pointer)?.value {
|
||||
CanonicalSourceValue::String(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn server_locations(
|
||||
ir: &NormalizedIr,
|
||||
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||
) -> Result<Vec<SourceLocation>, ImportParseError> {
|
||||
server_locations_for_source(&ir.source, nodes)
|
||||
}
|
||||
|
||||
fn server_locations_for_source(
|
||||
source: &ImportSourcePreview,
|
||||
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||
) -> Result<Vec<SourceLocation>, 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<Vec<CoverageEntry>, 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<Vec<SourceLocation>, 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::<Vec<_>>();
|
||||
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<String, &CanonicalSourceNode>,
|
||||
) -> (Vec<String>, Vec<SourceLocation>) {
|
||||
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<String, &CanonicalSourceNode>,
|
||||
) -> (Vec<String>, Vec<SourceLocation>) {
|
||||
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::<Vec<_>>(),
|
||||
),
|
||||
_ => 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<String, &CanonicalSourceNode>,
|
||||
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||
) -> 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<String, &CanonicalSourceNode>,
|
||||
operation_pointer: &str,
|
||||
tags: &[String],
|
||||
) -> Result<Vec<SourceLocation>, 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::<Vec<_>>();
|
||||
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<String, &CanonicalSourceNode>,
|
||||
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||
) -> 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<String, &CanonicalSourceNode>,
|
||||
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||
) -> 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<String, ExpectedCoverage>,
|
||||
) -> 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<String, ExpectedCoverage>,
|
||||
) -> 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<String, ExpectedCoverage>,
|
||||
) -> 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<String, ExpectedCoverage>,
|
||||
) -> 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<NormalizedReference> {
|
||||
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<NormalizedReference>) {
|
||||
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<CoverageEntry>,
|
||||
) -> 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<NormalizedPath> {
|
||||
let mut paths = BTreeMap::<String, Vec<String>>::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")
|
||||
}
|
||||
Reference in New Issue
Block a user