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:
2026-08-29 07:53:01 +00:00
parent 12b35e2c5f
commit 0a8d4d731f
2 changed files with 320 additions and 26 deletions
+273 -19
View File
@@ -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}"),
}
}
}