@@ -55,6 +55,10 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
5555 * Indicates whether the pointer references itself.
5656 */
5757 circular : boolean ;
58+ /**
59+ * Indicates whether the pointer encountered a cycle elsewhere in its reference chain.
60+ */
61+ chainCircular : boolean ;
5862 /**
5963 * The number of indirect references that were traversed to resolve the value.
6064 * Resolving a single pointer may require resolving multiple $Refs.
@@ -74,6 +78,8 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
7478
7579 this . circular = false ;
7680
81+ this . chainCircular = false ;
82+
7783 this . indirections = 0 ;
7884 }
7985
@@ -90,7 +96,13 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
9096 * the {@link Pointer#$ref} and {@link Pointer#path} will reflect the resolution path
9197 * of the resolved value.
9298 */
93- resolve ( obj : S , options ?: O , pathFromRoot ?: string ) {
99+ resolve (
100+ obj : S ,
101+ options ?: O ,
102+ pathFromRoot ?: string ,
103+ visitedRefPaths = new Set < string > ( ) ,
104+ resolveFinalReference = true ,
105+ ) {
94106 const tokens = Pointer . parse ( this . path , this . originalPath ) ;
95107 const found : string [ ] = [ ] ;
96108
@@ -108,15 +120,17 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
108120 // This prevents false circular detection when e.g. a root schema has both
109121 // $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
110122 const wasCircular = this . circular ;
123+ const wasChainCircular = this . chainCircular ;
111124 const isExtendedRef = $Ref . isExtended$Ref ( this . value ) ;
112- if ( resolveIf$Ref ( this , options , pathFromRoot ) ) {
125+ if ( resolveIf$Ref ( this , options , pathFromRoot , visitedRefPaths ) ) {
113126 // The $ref path has changed, so append the remaining tokens to the path
114127 this . path = Pointer . join ( this . path , tokens . slice ( i ) ) ;
115- } else if ( ! wasCircular && this . circular && isExtendedRef ) {
128+ } else if ( isExtendedRef ) {
116129 // resolveIf$Ref set circular=true on an extended $ref during token walking.
117130 // Since we still have tokens to process, the object should be walked by its
118131 // properties, not treated as a circular self-reference.
119- this . circular = false ;
132+ this . circular = wasCircular ;
133+ this . chainCircular = wasChainCircular ;
120134 }
121135
122136 const token = tokens [ i ] ;
@@ -134,6 +148,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
134148 }
135149 }
136150 if ( didFindSubstringSlashMatch ) {
151+ this . chainCircular = wasChainCircular ;
137152 continue ;
138153 }
139154
@@ -144,6 +159,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
144159 // We use a `null` symbol for internal tracking to differentiate between a general `null`
145160 // value and our expected `null` value.
146161 this . value = nullSymbol ;
162+ this . chainCircular = wasChainCircular ;
147163 continue ;
148164 }
149165
@@ -160,6 +176,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
160176 this . value = this . value [ token ] ;
161177 }
162178
179+ this . chainCircular = wasChainCircular ;
163180 found . push ( token ) ;
164181 if ( this . $ref . dynamicIdScope ) {
165182 this . scopeBase = getSchemaBasePath ( this . scopeBase , this . value ) ;
@@ -168,8 +185,20 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
168185
169186 // Resolve the final value
170187 const finalResolutionBase = this . $ref . dynamicIdScope ? this . scopeBase : this . path ;
171- if ( ! this . value || ( this . value . $ref && url . resolve ( finalResolutionBase , this . value . $ref ) !== pathFromRoot ) ) {
172- resolveIf$Ref ( this , options , pathFromRoot ) ;
188+ if ( resolveFinalReference ) {
189+ const finalRefPath = this . value ?. $ref ? url . resolve ( finalResolutionBase , this . value . $ref ) : undefined ;
190+ const canonicalPathFromRoot =
191+ typeof pathFromRoot === "string" ? url . resolve ( this . $ref . $refs . _root$Ref . path ! , pathFromRoot ) : pathFromRoot ;
192+
193+ if (
194+ $Ref . isAllowed$Ref ( this . value , options ) &&
195+ finalRefPath === canonicalPathFromRoot &&
196+ finalRefPath !== this . path
197+ ) {
198+ this . chainCircular = true ;
199+ } else if ( ! this . value || finalRefPath ) {
200+ resolveIf$Ref ( this , options , pathFromRoot , visitedRefPaths ) ;
201+ }
173202 }
174203
175204 return this ;
@@ -305,32 +334,74 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
305334 * @returns - Returns `true` if the resolution path changed
306335 */
307336function resolveIf$Ref < S extends object = JSONSchema , O extends ParserOptions < S > = ParserOptions < S > > (
308- pointer : Pointer ,
337+ pointer : Pointer < S , O > ,
309338 options : O | undefined ,
310339 pathFromRoot ?: string ,
340+ visitedRefPaths = new Set < string > ( ) ,
311341) {
312- // Is the value a JSON reference? (and allowed?)
342+ let pathChanged = false ;
343+ let currentPathFromRoot = pathFromRoot ;
344+ const addedPaths : string [ ] = [ ] ;
345+
346+ try {
347+ // Pure reference chains can be followed iteratively. Extended refs still use
348+ // the existing one-hop behavior because their values must be merged on return.
349+ while ( $Ref . isAllowed$Ref ( pointer . value , options ) ) {
350+ const extended = $Ref . isExtended$Ref ( pointer . value ) ;
351+ const sourceValue = pointer . value ;
352+ const parentPath = pointer . path ;
353+ const resolutionBase = pointer . $ref . dynamicIdScope ? pointer . scopeBase : pointer . path ;
354+ const $refPath = url . resolve ( resolutionBase , pointer . value . $ref ) ;
355+
356+ if ( $refPath === pointer . path && ! isRootPath ( currentPathFromRoot ) ) {
357+ // The value is a reference to itself, so there's nothing to do.
358+ pointer . circular = true ;
359+ pointer . chainCircular = true ;
360+ return pathChanged ;
361+ }
313362
314- if ( $Ref . isAllowed$Ref ( pointer . value , options ) ) {
315- const resolutionBase = pointer . $ref . dynamicIdScope ? pointer . scopeBase : pointer . path ;
316- const $refPath = url . resolve ( resolutionBase , pointer . value . $ref ) ;
363+ const canonicalPathFromRoot =
364+ typeof currentPathFromRoot === "string"
365+ ? url . resolve ( pointer . $ref . $refs . _root$Ref . path ! , currentPathFromRoot )
366+ : currentPathFromRoot ;
367+ if ( $refPath === canonicalPathFromRoot && $refPath !== pointer . path ) {
368+ pointer . chainCircular = true ;
369+ return pathChanged ;
370+ }
317371
318- if ( $refPath === pointer . path && ! isRootPath ( pathFromRoot ) ) {
319- // The value is a reference to itself, so there's nothing to do.
320- pointer . circular = true ;
321- } else {
322- const resolved = pointer . $ref . $refs . _resolve ( $refPath , pointer . path , options ) ;
372+ if ( visitedRefPaths . has ( $refPath ) ) {
373+ pointer . chainCircular = true ;
374+ return pathChanged ;
375+ }
376+
377+ const maxDepth = options ?. dereference ?. maxDepth ?? 500 ;
378+ if ( visitedRefPaths . size >= maxDepth ) {
379+ throw new RangeError (
380+ `Maximum dereference depth (${ maxDepth } ) exceeded at ${ pointer . path } . ` +
381+ `This likely indicates an extremely deep or recursive schema. ` +
382+ `You can increase this limit with the dereference.maxDepth option.` ,
383+ ) ;
384+ }
385+
386+ visitedRefPaths . add ( $refPath ) ;
387+ addedPaths . push ( $refPath ) ;
388+
389+ const resolved = pointer . $ref . $refs . _resolve ( $refPath , parentPath , options , visitedRefPaths , extended ) ;
323390 if ( resolved === null ) {
324- return false ;
391+ return pathChanged ;
325392 }
326393
327394 pointer . indirections += resolved . indirections + 1 ;
395+ pointer . chainCircular ||= resolved . circular || resolved . chainCircular ;
328396
329- if ( $Ref . isExtended$Ref ( pointer . value ) ) {
397+ if ( extended ) {
330398 // This JSON reference "extends" the resolved value, rather than simply pointing to it.
331399 // So the resolved path does NOT change. Just the value does.
332- pointer . value = $Ref . dereference ( pointer . value , resolved . value , options ) ;
333- return false ;
400+ if ( pointer . chainCircular ) {
401+ return pathChanged ;
402+ }
403+ pointer . value = $Ref . dereference ( sourceValue , resolved . value , options ) ;
404+ return pathChanged ;
334405 } else {
335406 // Resolve the reference
336407 pointer . $ref = resolved . $ref ;
@@ -339,13 +410,18 @@ function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
339410 // `pointer.$ref.path` is already the canonical location of the resolved resource.
340411 // Re-applying the resource's own `$id` here would duplicate nested path segments
341412 // such as `nested/nested/foo.json`.
342- pointer . scopeBase = pointer . $ref . path ! ;
413+ pointer . scopeBase = resolved . scopeBase ;
414+ currentPathFromRoot = parentPath ;
415+ pathChanged = true ;
343416 }
417+ }
344418
345- return true ;
419+ return pathChanged ;
420+ } finally {
421+ for ( const path of addedPaths ) {
422+ visitedRefPaths . delete ( path ) ;
346423 }
347424 }
348- return undefined ;
349425}
350426export default Pointer ;
351427
0 commit comments