@@ -289,6 +289,19 @@ export const jsonPath = (
289289 REGEX : 'regex' ,
290290 } as const ;
291291 const OPERATORS = [ '==' , '!=' , '<=' , '>=' , '*=' , '=~' , '<' , '>' , '=' ] ;
292+ // Hard ceilings for a single recursive descent. Cycles are detected by
293+ // object identity, but a non-caching proxy or getter that mints a fresh
294+ // wrapper on every property read defeats identity checks, so traversal
295+ // must also be bounded explicitly. Such a laundered cycle grows in depth
296+ // (and the depth cap stops it after a few thousand nodes), while large
297+ // legitimate payloads grow in width and stay far below both limits.
298+ //
299+ // Only object-valued candidates count toward MAX_RECURSIVE_CANDIDATES.
300+ // Primitive values (numbers, strings, booleans) can never form a cycle,
301+ // so counting them would fire the budget on large arrays of primitives.
302+ const MAX_RECURSIVE_CANDIDATES = 100000 ;
303+ const MAX_RECURSIVE_DEPTH = 1000 ;
304+
292305 // Required for appendPath(): simple property names can be emitted as `.prop`,
293306 // but keys with spaces, punctuation, or leading digits must use bracket
294307 // notation so generated paths stay valid and reparsable JSONPath selectors.
@@ -1262,6 +1275,13 @@ export const jsonPath = (
12621275 return [ ] ;
12631276 }
12641277
1278+ // Typed arrays (Uint8Array, Float32Array, etc.) expose every element
1279+ // as a numbered key. A large buffer would exhaust the traversal budget
1280+ // before reaching any meaningful property, so they are skipped entirely.
1281+ if ( ArrayBuffer . isView ( candidate . value ) ) {
1282+ return [ ] ;
1283+ }
1284+
12651285 const keys = Object . keys ( candidate . value ) ;
12661286 const output : JsonPathCandidate [ ] = [ ] ;
12671287
@@ -1276,25 +1296,81 @@ export const jsonPath = (
12761296 /**
12771297 * Returns a candidate plus all of its descendants.
12781298 *
1299+ * Cyclic structures are skipped by object identity. Inputs whose identity
1300+ * changes on every read (non-caching proxies, getters minting fresh
1301+ * wrappers) defeat identity checks, so traversal is additionally bounded
1302+ * by `MAX_RECURSIVE_CANDIDATES` and `MAX_RECURSIVE_DEPTH`; once a budget
1303+ * is exhausted the remaining branches are skipped and a message is logged.
1304+ *
12791305 * @param candidate root candidate for recursive descent
1280- * @returns recursive candidate list
1306+ * @returns recursive candidate list, possibly truncated to the budgets
12811307 */
12821308 function getRecursiveCandidates ( candidate : JsonPathCandidate ) : JsonPathCandidate [ ] {
12831309 const output : JsonPathCandidate [ ] = [ candidate ] ;
1310+ // Depth of each queued candidate, aligned with `output` by index
1311+ const candidateDepths : number [ ] = [ 0 ] ;
1312+ const visitedObjects = new WeakSet < object > ( ) ;
1313+ if ( isObjectLike ( candidate . value ) ) {
1314+ visitedObjects . add ( candidate . value ) ;
1315+ }
1316+
1317+ let isBudgetExhausted = false ;
1318+ // Counts only object-valued candidates. Primitives can never form a
1319+ // cycle so they are not counted, which avoids false positives on large
1320+ // arrays of numbers or strings.
1321+ let objectCandidateCount = isObjectLike ( candidate . value ) ? 1 : 0 ;
1322+
12841323 const initialChildren = getChildCandidates ( candidate ) ;
12851324 for ( let i = 0 ; i < initialChildren . length ; i += 1 ) {
1325+ if ( isObjectLike ( initialChildren [ i ] . value ) ) {
1326+ if ( visitedObjects . has ( initialChildren [ i ] . value ) ) {
1327+ continue ;
1328+ }
1329+ visitedObjects . add ( initialChildren [ i ] . value ) ;
1330+ objectCandidateCount += 1 ;
1331+ if ( objectCandidateCount >= MAX_RECURSIVE_CANDIDATES ) {
1332+ isBudgetExhausted = true ;
1333+ break ;
1334+ }
1335+ }
12861336 output . push ( initialChildren [ i ] ) ;
1337+ candidateDepths . push ( 1 ) ;
12871338 }
12881339
12891340 let head = 1 ;
1290- while ( head < output . length ) {
1341+ while ( head < output . length && ! isBudgetExhausted ) {
1342+ const childDepth = candidateDepths [ head ] + 1 ;
1343+ if ( childDepth > MAX_RECURSIVE_DEPTH ) {
1344+ isBudgetExhausted = true ;
1345+ break ;
1346+ }
1347+
12911348 const childCandidates = getChildCandidates ( output [ head ] ) ;
12921349 for ( let i = 0 ; i < childCandidates . length ; i += 1 ) {
1350+ if ( isObjectLike ( childCandidates [ i ] . value ) ) {
1351+ if ( visitedObjects . has ( childCandidates [ i ] . value ) ) {
1352+ continue ;
1353+ }
1354+ visitedObjects . add ( childCandidates [ i ] . value ) ;
1355+ objectCandidateCount += 1 ;
1356+ if ( objectCandidateCount >= MAX_RECURSIVE_CANDIDATES ) {
1357+ isBudgetExhausted = true ;
1358+ break ;
1359+ }
1360+ }
12931361 output . push ( childCandidates [ i ] ) ;
1362+ candidateDepths . push ( childDepth ) ;
12941363 }
12951364 head += 1 ;
12961365 }
12971366
1367+ if ( isBudgetExhausted ) {
1368+ logMessage (
1369+ source ,
1370+ 'JSONPath recursive descent exceeded its traversal budget, results may be incomplete' ,
1371+ ) ;
1372+ }
1373+
12981374 return output ;
12991375 }
13001376
@@ -1610,7 +1686,19 @@ export const jsonPath = (
16101686 for ( let i = 0 ; i < selector . steps . length ; i += 1 ) {
16111687 const step = selector . steps [ i ] ;
16121688 if ( step . mode === 'filter' && step . filter ) {
1613- candidates = applyFilterStep ( candidates , step . filter ) ;
1689+ // `$..[?(...)]` must test every descendant, so recursive
1690+ // filter steps expand candidates the same way direct steps do
1691+ let candidatesToFilter = candidates ;
1692+ if ( step . recursive ) {
1693+ candidatesToFilter = [ ] ;
1694+ for ( let j = 0 ; j < candidates . length ; j += 1 ) {
1695+ const recursiveCandidates = getRecursiveCandidates ( candidates [ j ] ) ;
1696+ for ( let k = 0 ; k < recursiveCandidates . length ; k += 1 ) {
1697+ candidatesToFilter . push ( recursiveCandidates [ k ] ) ;
1698+ }
1699+ }
1700+ }
1701+ candidates = applyFilterStep ( candidatesToFilter , step . filter ) ;
16141702 continue ;
16151703 }
16161704
0 commit comments