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
+260 -6
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,7 +148,8 @@ impl OpenAPISpec {
}
let mut paths = BTreeMap::new();
for (path, item) in paths_raw.as_object().expect("paths is object") {
if let Some(paths_obj) = paths_raw.as_object() {
for (path, item) in paths_obj {
if !item.is_object() {
continue;
}
@@ -158,6 +166,7 @@ impl OpenAPISpec {
}
paths.insert(path.clone(), PathItem { operations });
}
}
let components = raw
.get("components")
@@ -167,9 +176,11 @@ impl OpenAPISpec {
return None;
}
let mut map = HashMap::new();
for (k, v) in schemas.as_object().expect("schemas is object") {
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}"),
}
}
}
+47 -7
View File
@@ -1,7 +1,7 @@
---
id: review-001-ref-cycle-guard
name: Bounded, cycle-safe $ref resolution (OAI-01, OAI-08)
status: pending
status: completed
depends_on: []
scope: narrow
risk: high
@@ -34,11 +34,11 @@ Ride-along: **OAI-08** — guarded `expect`s in `openapi_spec.rs:144,170`
## Acceptance Criteria
- [ ] Import of a self-referential spec returns an error — the process does not abort (the review's named acceptance gate: a test importing a self-referential spec would have caught the abort immediately)
- [ ] Deeply-nested non-circular spec beyond the budget errors cleanly at import (test)
- [ ] A non-recursive spec with `$ref` sharing (refs to a common schema) still imports identically — no false cycle positives (existing suite green)
- [ ] OAI-08 `expect`s replaced
- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
- [x] Import of a self-referential spec returns an error — the process does not abort (the review's named acceptance gate: a test importing a self-referential spec would have caught the abort immediately)
- [x] Deeply-nested non-circular spec beyond the budget errors cleanly at import (test)
- [x] A non-recursive spec with `$ref` sharing (refs to a common schema) still imports identically — no false cycle positives (existing suite green)
- [x] OAI-08 `expect`s replaced
- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
## References
@@ -51,6 +51,46 @@ Ride-along: **OAI-08** — guarded `expect`s in `openapi_spec.rs:144,170`
> work (review-001-openapi-import-integrity) so the delicate
> cycle-detection change lands alone.
Implementation decisions:
- Depth budget set to 64 (`MAX_REF_RESOLUTION_DEPTH`, pub(crate) const in
openapi_spec.rs) — far above any legitimately expandable schema height,
2× serde_json's parse recursion limit. It bounds both `$ref` hop chains
and plain schema nesting.
- Clean loud error chosen over depth-limited expansion, per the task's
stated preference: recursive schemas (trees, linked lists, cursor
pagination) fail import with `AdapterError::SchemaParse` naming the
offending ref, rather than expanding unboundedly or truncating
silently.
- The visited set is branch-scoped (insert before recursing, remove on
return), so shared refs to one common schema — diamond or repeated —
resolve normally; only a ref re-entering its own expansion chain is a
cycle.
- OAI-08: both guarded `expect`s (`paths is object`, `schemas is
object`) replaced with `if let Some(..)`; the inner-body re-shape is
behavior-preserving (both sites were immediately preceded by an
`is_object()` check).
- Tests added in `src/adapters/openapi_spec.rs` (`mod tests`): direct
recursive + mutually-recursive error, over-deep non-circular error, two
no-false-positive cases (shared common schema, diamond reuse), and an
end-to-end `FromOpenAPI::import` of a self-referential spec asserting
an error return (process does not abort).
## Summary
> Filled on completion.
Implemented bounded, cycle-safe `$ref` resolution in
`src/adapters/openapi_spec.rs` (review 001 OAI-01 + OAI-08):
`resolve_refs_recursive` now delegates to a `resolve_refs_bounded`
worker carrying a branch-scoped `HashSet` of in-flight JSON-pointer refs
and a depth counter against `MAX_REF_RESOLUTION_DEPTH` (64). A ref
re-entering its own expansion chain errors with
`AdapterError::SchemaParse` ("circular $ref detected …: <ref>"); a
ref-chain or nesting height beyond the budget errors similarly ("exceeded
depth budget of 64"). Import of a self-referential spec therefore
returns a clean import error instead of aborting the process via stack
overflow (OAI-01); OAI-08's two guarded `expect`s in `from_value` became
plain `if let` paths. Six tests added in `openapi_spec.rs` covering the
cyclic, over-deep, and shared-ref matrices plus the named acceptance
gate (end-to-end self-referential import errors, no abort). 188 lib
tests green; full `cargo test`, `cargo clippy --all-targets -- -D
warnings`, and `cargo fmt --check` pass.