fix(adapters): bounded, cycle-safe $ref resolution (OAI-01, OAI-08)
resolve_refs_recursive recursed with no cycle detection and no depth
budget; a self-referential OpenAPI component stack-overflowed and
aborted the process (uncatchable, kills import()).
- add branch-scoped visited set on the JSON-pointer ref path: a ref
re-entering its own expansion chain errors cleanly with
AdapterError::SchemaParse naming the offending ref (OAI-01)
- add depth budget (MAX_REF_RESOLUTION_DEPTH = 64) bounding $ref hop
chains and schema nesting height; over-deep specs error cleanly
instead of exhausting the stack (OAI-01)
- clean loud error over depth-limited expansion: recursive schemas
(trees, linked lists, cursor pagination) fail import rather than
expand unboundedly
- shared refs to a common schema (diamond/repeated) still resolve —
visited set is branch-scoped, not global
- OAI-08: replace the two guarded expects in from_value
("paths is object", "schemas is object") with if-let paths
Verification: cargo test 188 passed / 0 failed; clippy
--all-targets -D warnings clean; fmt --check clean
This commit is contained in:
+273
-19
@@ -4,11 +4,18 @@
|
||||
//! 1.2; JSON-first detection under `from_str`), plus `$ref` resolution
|
||||
//! against the raw document.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use alkcall::client::AdapterError;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Maximum structural depth budget for recursive `$ref` resolution
|
||||
/// (review 001 OAI-01). Bounds both `$ref` hop chains and schema nesting
|
||||
/// height so a self-referential or pathologically deep component fails
|
||||
/// import with a clean [`AdapterError::SchemaParse`] instead of
|
||||
/// exhausting the stack.
|
||||
pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 64;
|
||||
|
||||
pub(crate) const HTTP_METHODS: &[&str] =
|
||||
&["get", "post", "put", "patch", "delete", "head", "options"];
|
||||
|
||||
@@ -141,22 +148,24 @@ impl OpenAPISpec {
|
||||
}
|
||||
|
||||
let mut paths = BTreeMap::new();
|
||||
for (path, item) in paths_raw.as_object().expect("paths is object") {
|
||||
if !item.is_object() {
|
||||
continue;
|
||||
}
|
||||
let mut operations = Vec::new();
|
||||
for method in HTTP_METHODS {
|
||||
if let Some(op_raw) = item.get(*method) {
|
||||
if let Some(op) = parse_operation(op_raw) {
|
||||
operations.push((method.to_string(), op));
|
||||
if let Some(paths_obj) = paths_raw.as_object() {
|
||||
for (path, item) in paths_obj {
|
||||
if !item.is_object() {
|
||||
continue;
|
||||
}
|
||||
let mut operations = Vec::new();
|
||||
for method in HTTP_METHODS {
|
||||
if let Some(op_raw) = item.get(*method) {
|
||||
if let Some(op) = parse_operation(op_raw) {
|
||||
operations.push((method.to_string(), op));
|
||||
}
|
||||
}
|
||||
}
|
||||
if operations.is_empty() {
|
||||
continue;
|
||||
}
|
||||
paths.insert(path.clone(), PathItem { operations });
|
||||
}
|
||||
if operations.is_empty() {
|
||||
continue;
|
||||
}
|
||||
paths.insert(path.clone(), PathItem { operations });
|
||||
}
|
||||
|
||||
let components = raw
|
||||
@@ -167,8 +176,10 @@ impl OpenAPISpec {
|
||||
return None;
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
for (k, v) in schemas.as_object().expect("schemas is object") {
|
||||
map.insert(k.clone(), v.clone());
|
||||
if let Some(schemas_obj) = schemas.as_object() {
|
||||
for (k, v) in schemas_obj {
|
||||
map.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Some(Components { schemas: map })
|
||||
});
|
||||
@@ -196,23 +207,71 @@ impl OpenAPISpec {
|
||||
Ok(current.clone())
|
||||
}
|
||||
|
||||
/// Resolve `$ref` pointers within `schema` against the raw document,
|
||||
/// returning a fully expanded copy.
|
||||
///
|
||||
/// Recursion is bounded: a visited set keyed on the JSON-pointer path
|
||||
/// rejects a `$ref` that is already being expanded on the current
|
||||
/// branch (a self/circular reference — trees, linked lists, cursor
|
||||
/// pagination), and a depth budget rejects pathologically deep
|
||||
/// nesting. Both surface as [`AdapterError::SchemaParse`] at import
|
||||
/// instead of recursing to stack exhaustion. Fully-expanded inlining
|
||||
/// of recursive schemas is deliberately not supported; specs that
|
||||
/// rely on it fail loudly here (review 001 OAI-01).
|
||||
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
|
||||
self.resolve_refs_bounded(schema, &mut HashSet::new(), 0)
|
||||
}
|
||||
|
||||
fn resolve_refs_bounded(
|
||||
&self,
|
||||
schema: &Value,
|
||||
resolving: &mut HashSet<String>,
|
||||
depth: usize,
|
||||
) -> Result<Value, AdapterError> {
|
||||
if depth > MAX_REF_RESOLUTION_DEPTH {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"$ref resolution exceeded depth budget of {MAX_REF_RESOLUTION_DEPTH} \
|
||||
(self-referential or pathologically nested schema)"
|
||||
),
|
||||
});
|
||||
}
|
||||
match schema {
|
||||
Value::Object(obj) => {
|
||||
if let Some(Value::String(reference)) = obj.get("$ref") {
|
||||
if !resolving.insert(reference.clone()) {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"circular $ref detected at depth {depth}: {reference}"
|
||||
),
|
||||
});
|
||||
}
|
||||
let resolved = self.resolve_ref(reference)?;
|
||||
return self.resolve_refs_recursive(&resolved);
|
||||
let out = self.resolve_refs_bounded(&resolved, resolving, depth + 1);
|
||||
resolving.remove(reference);
|
||||
return out;
|
||||
}
|
||||
if depth + 1 > MAX_REF_RESOLUTION_DEPTH {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"schema nesting exceeded depth budget of {MAX_REF_RESOLUTION_DEPTH} \
|
||||
(self-referential or pathologically nested schema)"
|
||||
),
|
||||
});
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in obj {
|
||||
out.insert(k.clone(), self.resolve_refs_recursive(v)?);
|
||||
out.insert(
|
||||
k.clone(),
|
||||
self.resolve_refs_bounded(v, resolving, depth + 1)?,
|
||||
);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for v in arr {
|
||||
out.push(self.resolve_refs_recursive(v)?);
|
||||
out.push(self.resolve_refs_bounded(v, resolving, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
@@ -283,3 +342,198 @@ fn parse_operation(raw: &Value) -> Option<Operation> {
|
||||
responses,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapters::{FromOpenAPI, HttpServiceConfig};
|
||||
use crate::client::{HttpClientConfig, SharedHttpClient};
|
||||
use alkcall::client::OperationAdapter;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn wrap_spec(raw: Value) -> OpenAPISpec {
|
||||
OpenAPISpec::from_value(raw).expect("test spec is valid")
|
||||
}
|
||||
|
||||
fn schema_test_spec(schema: Value) -> OpenAPISpec {
|
||||
wrap_spec(json!({
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/x": {"get": {"operationId": "x", "responses": {
|
||||
"200": {"content": {"application/json": {"schema": {}}}
|
||||
}}}}
|
||||
},
|
||||
"components": {"schemas": schema}
|
||||
}))
|
||||
}
|
||||
|
||||
fn nested_object(depth: usize) -> Value {
|
||||
let mut current = json!({"type": "string"});
|
||||
for _ in 0..depth {
|
||||
current = json!({"type": "object", "properties": {"child": current}});
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referential_ref_errors_instead_of_aborting() {
|
||||
let spec = schema_test_spec(json!({
|
||||
"Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"next": {"$ref": "#/components/schemas/Node"}
|
||||
}
|
||||
}
|
||||
}));
|
||||
let schema = spec
|
||||
.components
|
||||
.as_ref()
|
||||
.and_then(|c| c.schemas.get("Node"))
|
||||
.expect("test spec has Node")
|
||||
.clone();
|
||||
let result = spec.resolve_refs_recursive(&schema);
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("circular $ref"), "message was: {message}");
|
||||
assert!(message.contains("Node"), "message was: {message}");
|
||||
}
|
||||
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutually_recursive_refs_error_on_cycle() {
|
||||
let spec = schema_test_spec(json!({
|
||||
"A": {"properties": {"b": {"$ref": "#/components/schemas/B"}}},
|
||||
"B": {"properties": {"a": {"$ref": "#/components/schemas/A"}}}
|
||||
}));
|
||||
let schema = spec
|
||||
.components
|
||||
.as_ref()
|
||||
.and_then(|c| c.schemas.get("A"))
|
||||
.expect("test spec has A")
|
||||
.clone();
|
||||
let result = spec.resolve_refs_recursive(&schema);
|
||||
assert!(matches!(result, Err(AdapterError::SchemaParse { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_deep_non_circular_spec_errors_cleanly() {
|
||||
let depth = MAX_REF_RESOLUTION_DEPTH * 4;
|
||||
let spec = schema_test_spec(json!({"Deep": nested_object(depth)}));
|
||||
let schema = spec
|
||||
.components
|
||||
.as_ref()
|
||||
.and_then(|c| c.schemas.get("Deep"))
|
||||
.expect("test spec has Deep")
|
||||
.clone();
|
||||
let result = spec.resolve_refs_recursive(&schema);
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("depth budget"), "message was: {message}");
|
||||
}
|
||||
other => panic!("expected depth-budget SchemaParse error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_refs_to_common_schema_import_identically() {
|
||||
let spec = schema_test_spec(json!({
|
||||
"Money": {"type": "object", "properties": {"amount": {"type": "number"}}},
|
||||
"Order": {
|
||||
"type": "object",
|
||||
"properties": {"total": {"$ref": "#/components/schemas/Money"}}
|
||||
},
|
||||
"Refund": {
|
||||
"type": "object",
|
||||
"properties": {"amount": {"$ref": "#/components/schemas/Money"}}
|
||||
}
|
||||
}));
|
||||
let schemas = &spec
|
||||
.components
|
||||
.as_ref()
|
||||
.expect("test spec has schemas")
|
||||
.schemas;
|
||||
for name in ["Order", "Refund"] {
|
||||
let schema = schemas.get(name).expect("test schema present").clone();
|
||||
let resolved = spec
|
||||
.resolve_refs_recursive(&schema)
|
||||
.expect("no false cycle");
|
||||
let money = &resolved["properties"][if name == "Order" { "total" } else { "amount" }];
|
||||
assert_eq!(money["type"], "object");
|
||||
assert_eq!(money["properties"]["amount"]["type"], "number");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diamond_ref_reuse_within_one_schema_does_not_trip_cycle_guard() {
|
||||
let spec = schema_test_spec(json!({
|
||||
"Id": {"type": "string"},
|
||||
"Wrapper": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"$ref": "#/components/schemas/Id"},
|
||||
"b": {"$ref": "#/components/schemas/Id"}
|
||||
}
|
||||
}
|
||||
}));
|
||||
let schema = spec
|
||||
.components
|
||||
.as_ref()
|
||||
.expect("test spec has schemas")
|
||||
.schemas
|
||||
.get("Wrapper")
|
||||
.expect("test schema present")
|
||||
.clone();
|
||||
let resolved = spec
|
||||
.resolve_refs_recursive(&schema)
|
||||
.expect("no false cycle");
|
||||
assert_eq!(resolved["properties"]["a"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_of_self_referential_spec_returns_error_not_abort() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"components": {"schemas": {
|
||||
"Node": {
|
||||
"type": "object",
|
||||
"properties": {"next": {"$ref": "#/components/schemas/Node"}}
|
||||
}
|
||||
}},
|
||||
"paths": {
|
||||
"/nodes": {"get": {"operationId": "listNodes", "responses": {
|
||||
"200": {"content": {"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Node"}
|
||||
}}}
|
||||
}}}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let client = SharedHttpClient::new(HttpClientConfig::default()).unwrap();
|
||||
let adapter = FromOpenAPI::new(
|
||||
spec,
|
||||
HttpServiceConfig {
|
||||
namespace: "svc".to_string(),
|
||||
base_url: "https://x".to_string(),
|
||||
auth: None,
|
||||
default_headers: HashMap::new(),
|
||||
},
|
||||
Arc::new(client),
|
||||
);
|
||||
let result = adapter.import();
|
||||
match futures::executor::block_on(result) {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("circular $ref"), "message was: {message}");
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected import error for recursive spec, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected circular-$ref SchemaParse, got {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user