888 lines
28 KiB
Rust
888 lines
28 KiB
Rust
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")
|
|
}
|