1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// APCore Protocol — Schema reference resolver
// Spec reference: JSON $ref resolution and circular reference detection
use std::collections::{HashMap, HashSet};
use crate::errors::{ErrorCode, ModuleError, SchemaCircularRefError};
/// Default maximum depth for `$ref` resolution. Matches apcore-python
/// (`schema.max_ref_depth = 32`) and apcore-typescript.
pub const DEFAULT_MAX_REF_DEPTH: usize = 32;
/// Resolves $ref references in JSON schemas.
#[derive(Debug)]
pub struct RefResolver {
schemas: HashMap<String, serde_json::Value>,
max_depth: usize,
}
impl RefResolver {
/// Create a new ref resolver with the default max depth.
#[must_use]
pub fn new() -> Self {
Self {
schemas: HashMap::new(),
max_depth: DEFAULT_MAX_REF_DEPTH,
}
}
/// Create a ref resolver with an explicit `max_depth` for `$ref` recursion.
#[must_use]
pub fn with_max_depth(max_depth: usize) -> Self {
Self {
schemas: HashMap::new(),
max_depth,
}
}
/// Returns the configured maximum recursion depth for `$ref` resolution.
#[must_use]
pub fn max_depth(&self) -> usize {
self.max_depth
}
/// Register a schema that can be referenced.
pub fn register(&mut self, uri: &str, schema: serde_json::Value) {
self.schemas.insert(uri.to_string(), schema);
}
/// Resolve all $ref references in a schema, returning a fully dereferenced schema.
pub fn resolve(&self, schema: &serde_json::Value) -> Result<serde_json::Value, ModuleError> {
let mut seen = HashSet::new();
self.resolve_inner(schema, schema, &mut seen, 0)
}
/// Check if a schema contains circular references.
#[must_use]
pub fn has_circular_refs(&self, schema: &serde_json::Value) -> bool {
let mut seen = HashSet::new();
self.check_circular(schema, schema, &mut seen)
}
/// Recursively resolve $ref nodes.
fn resolve_inner(
&self,
node: &serde_json::Value,
root: &serde_json::Value,
seen: &mut HashSet<String>,
depth: usize,
) -> Result<serde_json::Value, ModuleError> {
if depth >= self.max_depth {
// A-D-038: depth-cap exhaustion is distinct from an actual cycle.
// Emit SCHEMA_MAX_DEPTH_EXCEEDED here; the genuine-cycle branch
// below (seen.contains) emits SCHEMA_CIRCULAR_REF. Cross-SDK note:
// apcore-python/typescript currently report CIRCULAR_REF for the
// depth cap too — Rust is the canonical here and they should follow.
let mut details = std::collections::HashMap::new();
details.insert(
"max_depth".to_string(),
serde_json::Value::from(self.max_depth),
);
return Err(ModuleError::new(
ErrorCode::SchemaMaxDepthExceeded,
format!(
"Schema $ref recursion exceeded max_depth={} (sync SCHEMA-001)",
self.max_depth
),
)
.with_details(details));
}
match node {
serde_json::Value::Object(map) => {
// If this node is a $ref, resolve it
if let Some(ref_val) = map.get("$ref") {
if let Some(ref_str) = ref_val.as_str() {
if seen.contains(ref_str) {
return Err(SchemaCircularRefError::new(
format!("Circular $ref detected: {ref_str}"),
ref_str.to_string(),
)
.to_module_error());
}
seen.insert(ref_str.to_string());
let resolved = self.lookup_ref(ref_str, root)?;
// Sync finding A-D-028: increment `depth` ONLY when
// following a $ref (this is the recursion the spec's
// max_depth=32 cap targets). Apcore-python and
// apcore-typescript also bump depth only on $ref
// dereferencing — Rust previously incremented on every
// child object/array element, so a flat 33-property
// schema with no $refs threw SCHEMA_MAX_DEPTH_EXCEEDED.
let result = self.resolve_inner(&resolved, root, seen, depth + 1)?;
seen.remove(ref_str);
return Ok(result);
}
}
// Otherwise walk all children — same `depth`. Tree traversal
// through map/array children does not consume the $ref budget.
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(k.clone(), self.resolve_inner(v, root, seen, depth)?);
}
Ok(serde_json::Value::Object(new_map))
}
serde_json::Value::Array(arr) => {
let resolved: Result<Vec<_>, _> = arr
.iter()
.map(|v| self.resolve_inner(v, root, seen, depth))
.collect();
Ok(serde_json::Value::Array(resolved?))
}
other => Ok(other.clone()),
}
}
/// Look up a $ref string, supporting local (#/definitions/..., #/$defs/...)
/// and registered URI references.
fn lookup_ref(
&self,
ref_str: &str,
root: &serde_json::Value,
) -> Result<serde_json::Value, ModuleError> {
if let Some(pointer) = ref_str.strip_prefix('#') {
// Local reference: walk the JSON pointer path
if pointer.is_empty() {
return Ok(root.clone());
}
root.pointer(pointer).cloned().ok_or_else(|| {
ModuleError::new(
ErrorCode::SchemaNotFound,
format!("Local $ref not found: {ref_str}"),
)
})
} else {
// Registered URI reference
self.schemas.get(ref_str).cloned().ok_or_else(|| {
ModuleError::new(
ErrorCode::SchemaNotFound,
format!("Referenced schema not found: {ref_str}"),
)
})
}
}
/// Recursively check for circular $ref paths.
fn check_circular(
&self,
node: &serde_json::Value,
root: &serde_json::Value,
seen: &mut HashSet<String>,
) -> bool {
match node {
serde_json::Value::Object(map) => {
if let Some(ref_val) = map.get("$ref") {
if let Some(ref_str) = ref_val.as_str() {
if seen.contains(ref_str) {
return true;
}
seen.insert(ref_str.to_string());
if let Ok(resolved) = self.lookup_ref(ref_str, root) {
let circular = self.check_circular(&resolved, root, seen);
seen.remove(ref_str);
return circular;
}
seen.remove(ref_str);
return false;
}
}
for v in map.values() {
if self.check_circular(v, root, seen) {
return true;
}
}
false
}
serde_json::Value::Array(arr) => {
for v in arr {
if self.check_circular(v, root, seen) {
return true;
}
}
false
}
_ => false,
}
}
}
impl Default for RefResolver {
fn default() -> Self {
Self::new()
}
}