From 84bffae6970cd42ac9aefbb947693e8bb6b10370 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 19:34:20 +0000 Subject: [PATCH] wip(adapters): OAI-11 finisher checkpoint 2 --- src/adapters/openapi_spec.rs | 97 +++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index 8a91c59..61f229f 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -16,6 +16,16 @@ use serde_json::Value; /// exhausting the stack. pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 64; +/// Maximum number of `Value` nodes materialized by one `resolve_refs_recursive` +/// call (review 002 OAI-11). Memoization makes repeated `$ref` hops cheap, but +/// the fully-inlined output of an acyclic shared-ref chain still grows +/// exponentially in the chain length — a doubly-linked-list spec +/// (`S_i` referenced by both `a` and `b` of `S_{i-1}`) inlines to ~2^N nodes. +/// Neither the visited set nor the depth budget fires for that shape; this +/// budget does, failing import with a clean [`AdapterError::SchemaParse`] +/// instead of wedging with no error (or exhausting memory). +pub(crate) const MAX_REF_EXPANSION_NODES: usize = 1_000_000; + /// The `paths`-level HTTP methods the adapter models. `trace` is /// deliberately absent (OAI-06): a path carrying only unsupported /// methods is skipped with a warning, not imported as a mis-behaving op. @@ -375,7 +385,7 @@ impl OpenAPISpec { /// cycle-free `$ref` expansions ensures each distinct ref target is /// expanded once and reused by clone on every later hop (review 002 /// OAI-11) — an acyclic shared-ref chain grows linearly instead of - /// as an exponential tree. All three surface as + /// as an exponential tree. All four surface as /// [`AdapterError::SchemaParse`] at import instead of recursing to /// stack exhaustion or wedging without an error. Fully-expanded /// inlining of recursive schemas is deliberately not supported; specs @@ -383,6 +393,11 @@ impl OpenAPISpec { /// OAI-11). Memo entries are written only after a successful /// expansion, so a partial expansion under a cyclic branch is never /// cached and cycle detection semantics are unchanged. + /// + /// As the final bound (a memo alone cannot shrink the inlined output of + /// an acyclic exponential shared-ref chain), a per-call node budget + /// counts every materialized `Value` node; exceeding it fails import + /// with a clean error naming the budget (OAI-11). pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result { self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0) } @@ -401,6 +416,16 @@ impl OpenAPISpec { ), }); } + state.nodes = state.nodes.saturating_add(1); + if state.nodes > MAX_REF_EXPANSION_NODES { + return Err(AdapterError::SchemaParse { + message: format!( + "$ref expansion exceeded node budget of {MAX_REF_EXPANSION_NODES} \ + (acyclic shared-$ref chain expanding exponentially; the schema is \ + valid but its full inline expansion is too large to materialize)" + ), + }); + } match schema { Value::Object(obj) => { if let Some(Value::String(reference)) = obj.get("$ref") { @@ -447,13 +472,15 @@ impl OpenAPISpec { } } -/// Branch-scoped state for [`OpenAPISpec::resolve_refs_recursive`]: the -/// in-flight `$ref` chain (cycle detection) and the memo of completed -/// cycle-free expansions (total-work bounding, review 002 OAI-11). +/// Cycle-scoped state for [`OpenAPISpec::resolve_refs_recursive`]: the +/// in-flight `$ref` chain (cycle detection), the memo of completed +/// cycle-free expansions (total-work bounding, review 002 OAI-11), and the +/// per-call node counter (the last-resort bound on inlined output size). #[derive(Default)] struct RefResolution { resolving: HashSet, memo: HashMap, + nodes: usize, } fn parse_operation( @@ -845,8 +872,8 @@ mod tests { } #[test] - fn thirty_level_shared_chain_imports_bounded() { - let levels = 30usize; + fn thirty_level_shared_chain_returns_bounded_with_clean_budget_error() { + let levels = 32usize; let mut components = serde_json::Map::new(); for i in 0..levels { let next_ref = if i + 1 < levels { @@ -869,24 +896,60 @@ mod tests { .expect("test schema present") .clone(); let start = std::time::Instant::now(); - let resolved = spec - .resolve_refs_recursive(&schema) - .expect("acyclic shared chain resolves"); + let result = spec.resolve_refs_recursive(&schema); let elapsed = start.elapsed(); assert!( elapsed.as_secs_f64() < 1.0, - "30+ level shared chain must import in < 1 s, took {elapsed:?}" + "30+ level shared chain must return in < 1 s, took {elapsed:?}" ); - let a = &resolved["a"]; - assert_eq!(a["a"]["a"]["type"], "string"); - for side in ["a", "b"] { - assert_eq!( - resolved[side]["a"]["type"], "string", - "both branches fully expanded" - ); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("node budget"), + "message was: {message}" + ); + } + other => panic!("expected node-budget SchemaParse error, got {other:?}"), } } + #[test] + fn forty_level_single_chain_imports_fast_under_memoization() { + let levels = 40usize; + let mut components = serde_json::Map::new(); + for i in 0..levels { + let next = if i + 1 < levels { + json!({"$ref": format!("#/components/schemas/S{}", i + 1)}) + } else { + json!({"type": "string"}) + }; + components.insert(format!("S{i}"), json!({"next": next})); + } + let spec = schema_test_spec(Value::Object(components)); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("S0") + .expect("test schema present") + .clone(); + let start = std::time::Instant::now(); + let resolved = spec + .resolve_refs_recursive(&schema) + .expect("acyclic chain resolves"); + let elapsed = start.elapsed(); + assert!( + elapsed.as_secs_f64() < 1.0, + "40-level chain must resolve in < 1 s, took {elapsed:?}" + ); + let mut cursor = &resolved; + for _ in 1..levels { + cursor = &cursor["next"]; + } + assert_eq!(cursor["type"], "string", "innermost level fully expanded"); + } + #[test] fn memoized_expansion_matches_pre_memoization_golden() { let spec = schema_test_spec(json!({