Skip to content

Commit cd9737c

Browse files
authored
fix: resolve multiple dereference and bundle issues (#338, #370, #395) (#409)
- Fix #370: Reset false circular detection for extended $refs during pointer token walking to handle Pydantic-style schemas with $ref alongside $defs - Fix #338: Add post-processing in bundle() to correct $ref paths that incorrectly traverse through other $ref nodes - Fix #395: Add maxDepth option to dereference (default 500) to prevent stack overflow on deeply nested schemas - All fixes include comprehensive tests and pass full test suite (298 tests)
1 parent 78b3323 commit cd9737c

File tree

13 files changed

+425
-1
lines changed

13 files changed

+425
-1
lines changed

lib/bundle.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
4141

4242
// Remap all $ref pointers
4343
remap<S, O>(inventory, options);
44+
45+
// Fix any $ref paths that traverse through other $refs (which is invalid per JSON Schema spec)
46+
fixRefsThroughRefs(inventory, parser.schema as any);
4447
}
4548

4649
/**
@@ -316,4 +319,94 @@ function removeFromInventory(inventory: InventoryEntry[], entry: any) {
316319
const index = inventory.indexOf(entry);
317320
inventory.splice(index, 1);
318321
}
322+
323+
/**
324+
* After remapping, some $ref paths may traverse through other $ref nodes.
325+
* JSON pointer resolution does not follow $ref indirection, so these paths are invalid.
326+
* This function detects and fixes such paths by following any intermediate $refs
327+
* to compute a valid direct path.
328+
*/
329+
function fixRefsThroughRefs(inventory: InventoryEntry[], schema: any) {
330+
for (const entry of inventory) {
331+
if (!entry.$ref || typeof entry.$ref !== "object" || !("$ref" in entry.$ref)) {
332+
continue;
333+
}
334+
335+
const refValue = entry.$ref.$ref;
336+
if (typeof refValue !== "string" || !refValue.startsWith("#/")) {
337+
continue;
338+
}
339+
340+
const fixedPath = resolvePathThroughRefs(schema, refValue);
341+
if (fixedPath !== refValue) {
342+
entry.$ref.$ref = fixedPath;
343+
}
344+
}
345+
}
346+
347+
/**
348+
* Walks a JSON pointer path through the schema. If any intermediate value
349+
* is a $ref, follows it and adjusts the path accordingly.
350+
* Returns the corrected path that doesn't traverse through any $ref.
351+
*/
352+
function resolvePathThroughRefs(schema: any, refPath: string): string {
353+
if (!refPath.startsWith("#/")) {
354+
return refPath;
355+
}
356+
357+
const segments = refPath.slice(2).split("/");
358+
let current = schema;
359+
const resolvedSegments: string[] = [];
360+
361+
for (const seg of segments) {
362+
if (current === null || current === undefined || typeof current !== "object") {
363+
// Can't walk further, return original path
364+
return refPath;
365+
}
366+
367+
// If the current value is a $ref, follow it
368+
if ("$ref" in current && typeof current.$ref === "string" && current.$ref.startsWith("#/")) {
369+
// Follow the $ref and restart the path from its target
370+
const targetSegments = current.$ref.slice(2).split("/");
371+
resolvedSegments.length = 0;
372+
resolvedSegments.push(...targetSegments);
373+
current = walkPath(schema, current.$ref);
374+
if (current === null || current === undefined || typeof current !== "object") {
375+
return refPath;
376+
}
377+
}
378+
379+
const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
380+
const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
381+
current = current[idx];
382+
resolvedSegments.push(seg);
383+
}
384+
385+
const result = "#/" + resolvedSegments.join("/");
386+
return result;
387+
}
388+
389+
/**
390+
* Walks a JSON pointer path through a schema object, returning the value at that path.
391+
*/
392+
function walkPath(schema: any, path: string): any {
393+
if (!path.startsWith("#/")) {
394+
return undefined;
395+
}
396+
397+
const segments = path.slice(2).split("/");
398+
let current = schema;
399+
400+
for (const seg of segments) {
401+
if (current === null || current === undefined || typeof current !== "object") {
402+
return undefined;
403+
}
404+
const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
405+
const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
406+
current = current[idx];
407+
}
408+
409+
return current;
410+
}
411+
319412
export default bundle;

lib/dereference.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
3131
parser.$refs,
3232
options,
3333
start,
34+
0,
3435
);
3536
parser.$refs.circular = dereferenced.circular;
3637
parser.schema = dereferenced.value;
@@ -48,6 +49,7 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
4849
* @param $refs
4950
* @param options
5051
* @param startTime - The time when the dereferencing started
52+
* @param depth - The current recursion depth
5153
* @returns
5254
*/
5355
function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
@@ -60,6 +62,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
6062
$refs: $Refs<S, O>,
6163
options: O,
6264
startTime: number,
65+
depth: number,
6366
) {
6467
let dereferenced;
6568
const result = {
@@ -70,6 +73,14 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
7073
checkDereferenceTimeout<S, O>(startTime, options);
7174

7275
const derefOptions = (options.dereference || {}) as DereferenceOptions;
76+
const maxDepth = derefOptions.maxDepth ?? 500;
77+
if (depth > maxDepth) {
78+
throw new RangeError(
79+
`Maximum dereference depth (${maxDepth}) exceeded at ${pathFromRoot}. ` +
80+
`This likely indicates an extremely deep or recursive schema. ` +
81+
`You can increase this limit with the dereference.maxDepth option.`,
82+
);
83+
}
7384
const isExcludedPath = derefOptions.excludedPathMatcher || (() => false);
7485

7586
if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) {
@@ -88,6 +99,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
8899
$refs,
89100
options,
90101
startTime,
102+
depth,
91103
);
92104
result.circular = dereferenced.circular;
93105
result.value = dereferenced.value;
@@ -116,6 +128,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
116128
$refs,
117129
options,
118130
startTime,
131+
depth,
119132
);
120133
circular = dereferenced.circular;
121134
// Avoid pointless mutations; breaks frozen objects to no profit
@@ -159,6 +172,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
159172
$refs,
160173
options,
161174
startTime,
175+
depth + 1,
162176
);
163177
circular = dereferenced.circular;
164178
// Avoid pointless mutations; breaks frozen objects to no profit
@@ -205,6 +219,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
205219
$refs: $Refs<S, O>,
206220
options: O,
207221
startTime: number,
222+
depth: number,
208223
) {
209224
const isExternalRef = $Ref.isExternal$Ref($ref);
210225
const shouldResolveOnCwd = isExternalRef && options?.dereference?.externalReferenceResolution === "root";
@@ -295,6 +310,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
295310
$refs,
296311
options,
297312
startTime,
313+
depth + 1,
298314
);
299315
circular = dereferenced.circular;
300316
dereferencedValue = dereferenced.value;

lib/options.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ export interface DereferenceOptions {
8989
* Default: `true`
9090
*/
9191
mergeKeys?: boolean;
92+
93+
/**
94+
* The maximum recursion depth for dereferencing nested schemas.
95+
* If the schema nesting exceeds this depth, a RangeError will be thrown
96+
* with a descriptive message instead of crashing with a stack overflow.
97+
*
98+
* Default: 500
99+
*/
100+
maxDepth?: number;
92101
}
93102

94103
/**

lib/pointer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,22 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
8989
this.value = unwrapOrThrow(obj);
9090

9191
for (let i = 0; i < tokens.length; i++) {
92+
// During token walking, if the current value is an extended $ref (has sibling keys
93+
// alongside $ref, as allowed by JSON Schema 2019-09+), and resolveIf$Ref marks it
94+
// as circular because the $ref resolves to the same path we're walking, we should
95+
// reset the circular flag and continue walking the object's own properties.
96+
// This prevents false circular detection when e.g. a root schema has both
97+
// $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
98+
const wasCircular = this.circular;
99+
const isExtendedRef = $Ref.isExtended$Ref(this.value);
92100
if (resolveIf$Ref(this, options, pathFromRoot)) {
93101
// The $ref path has changed, so append the remaining tokens to the path
94102
this.path = Pointer.join(this.path, tokens.slice(i));
103+
} else if (!wasCircular && this.circular && isExtendedRef) {
104+
// resolveIf$Ref set circular=true on an extended $ref during token walking.
105+
// Since we still have tokens to process, the object should be walked by its
106+
// properties, not treated as a circular self-reference.
107+
this.circular = false;
95108
}
96109

97110
const token = tokens[i];
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect } from "vitest";
2+
import $RefParser from "../../../lib/index.js";
3+
import path from "../../utils/path.js";
4+
5+
describe("Issue #338: bundle() should not create refs through refs", () => {
6+
it("should produce valid bundled output where no $ref path traverses through another $ref", async () => {
7+
const parser = new $RefParser();
8+
const schema = await parser.bundle(path.rel("test/specs/bundle-ref-through-ref/schemaA.json"));
9+
10+
const bundledStr = JSON.stringify(schema, null, 2);
11+
12+
// Collect all $ref values in the bundled schema
13+
const refs: string[] = [];
14+
function collectRefs(obj: any, path: string) {
15+
if (!obj || typeof obj !== "object") return;
16+
if (Array.isArray(obj)) {
17+
obj.forEach((item, i) => collectRefs(item, `${path}/${i}`));
18+
return;
19+
}
20+
for (const [key, val] of Object.entries(obj)) {
21+
if (key === "$ref" && typeof val === "string") {
22+
refs.push(val);
23+
} else {
24+
collectRefs(val, `${path}/${key}`);
25+
}
26+
}
27+
}
28+
collectRefs(schema, "#");
29+
30+
// For each $ref, verify the path can be resolved by walking the literal
31+
// object structure (without following $ref indirection)
32+
for (const ref of refs) {
33+
if (!ref.startsWith("#/")) continue;
34+
35+
const segments = ref.slice(2).split("/");
36+
let current: any = schema;
37+
let valid = true;
38+
let failedAt = "";
39+
40+
for (const seg of segments) {
41+
if (current === null || current === undefined || typeof current !== "object") {
42+
valid = false;
43+
failedAt = seg;
44+
break;
45+
}
46+
// If the current value at this path is itself a $ref, the path is invalid
47+
// (JSON pointer resolution doesn't follow $ref indirection)
48+
if ("$ref" in current && typeof current.$ref === "string" && seg !== "$ref") {
49+
// This position has a $ref - the pointer can't traverse through it
50+
valid = false;
51+
failedAt = `$ref at ${seg}`;
52+
break;
53+
}
54+
const idx = Array.isArray(current) ? parseInt(seg) : seg;
55+
current = current[idx];
56+
}
57+
58+
expect(valid, `$ref "${ref}" traverses through another $ref (failed at: ${failedAt}). Bundled:\n${bundledStr}`).toBe(
59+
true,
60+
);
61+
}
62+
});
63+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$id": "schemaA/1.0",
3+
"$schema": "http://json-schema.org/draft-07/schema#",
4+
"type": "object",
5+
"$ref": "schemaB.json#/definitions/SupplierPriceElement"
6+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"$id": "schemaC/1.0",
3+
"$schema": "http://json-schema.org/draft-07/schema#",
4+
"definitions": {
5+
"SupplierPriceElement": {
6+
"type": "object",
7+
"properties": {
8+
"purchaseRate": {
9+
"$ref": "#/definitions/InDetailParent"
10+
},
11+
"fee": {
12+
"$ref": "#/definitions/AllFees"
13+
}
14+
}
15+
},
16+
"AllFees": {
17+
"type": "object",
18+
"properties": {
19+
"modificationFee": {
20+
"$ref": "#/definitions/MonetaryAmount"
21+
}
22+
}
23+
},
24+
"MonetaryAmount": {
25+
"type": "object",
26+
"properties": {
27+
"amount": {
28+
"$ref": "#/definitions/Amount"
29+
}
30+
}
31+
},
32+
"Amount": {
33+
"type": "number",
34+
"format": "float"
35+
},
36+
"InDetailParent": {
37+
"allOf": [
38+
{
39+
"$ref": "#/definitions/MonetaryAmount"
40+
},
41+
{
42+
"type": "object",
43+
"$ref": "#/definitions/Amount"
44+
}
45+
]
46+
}
47+
}
48+
}

test/specs/bundle/bundled.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default {
1616
},
1717
{
1818
type: "object",
19-
$ref: "#/properties/fee/properties/modificationFee/properties/amount",
19+
$ref: "#/properties/purchaseRate/allOf/0/properties/amount",
2020
},
2121
],
2222
},
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, it, expect } from "vitest";
2+
import $RefParser from "../../../lib/index.js";
3+
import path from "../../utils/path.js";
4+
5+
describe("Circular $ref to self via filename vs hash", () => {
6+
it("should dereference $ref: '#' with circular reference at top level", async () => {
7+
const parser = new $RefParser();
8+
const schema = await parser.dereference(path.rel("test/specs/circular-external-self/recursive-self.json"));
9+
10+
expect(parser.$refs.circular).to.equal(true);
11+
// When using $ref: "#", the value property should directly be a circular reference to the schema itself
12+
expect(schema.properties.value).to.equal(schema);
13+
});
14+
15+
it("should dereference $ref: 'recursive-filename.json' with circular reference at top level", async () => {
16+
const parser = new $RefParser();
17+
const schema = await parser.dereference(
18+
path.rel("test/specs/circular-external-self/recursive-filename.json"),
19+
);
20+
21+
expect(parser.$refs.circular).to.equal(true);
22+
// When using $ref: "recursive-filename.json", the value property should ALSO directly
23+
// be a circular reference to the schema itself (not one level too deep)
24+
// Issue #378: this was producing schema.properties.value !== schema (one level too deep)
25+
expect(schema.properties.value).to.equal(schema);
26+
});
27+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"type": "object",
3+
"properties": {
4+
"name": {
5+
"type": "string"
6+
},
7+
"value": {
8+
"$ref": "recursive-filename.json"
9+
}
10+
}
11+
}

0 commit comments

Comments
 (0)