bfj 9.1.2 → 9.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm info:*)",
5
+ "Bash(npm view:*)",
6
+ "Bash(node -e:*)",
7
+ "WebSearch",
8
+ "Bash(npx mocha:*)",
9
+ "Bash(export PATH:*)",
10
+ "Bash(node:*)",
11
+ "Bash(npm install:*)",
12
+ "Bash(npm run unit:*)",
13
+ "Bash(npm run lint:*)",
14
+ "Bash(/usr/bin/tail:*)",
15
+ "Bash(/usr/bin/grep -E '\\(passing|failing\\)')",
16
+ "Bash(npm run integration:*)",
17
+ "Bash(npm test:*)",
18
+ "Bash(npm run memory:*)"
19
+ ]
20
+ }
21
+ }
package/.eslintrc CHANGED
@@ -43,7 +43,7 @@ rules:
43
43
  keyword-spacing: [ 2, { "before": true, "after": true } ]
44
44
  linebreak-style: [ 2, "unix" ]
45
45
  lines-around-comment: 0
46
- max-depth: [ 1, 3 ]
46
+ max-depth: [ 1, 4 ]
47
47
  max-nested-callbacks: [ 1, 2 ]
48
48
  max-params: [ 1, 8 ]
49
49
  max-statements: 0
@@ -62,9 +62,9 @@ rules:
62
62
  no-cond-assign: [ 2, "always" ]
63
63
  no-confusing-arrow: 0
64
64
  no-console: 1
65
- no-constant-condition: 2
65
+ no-constant-condition: 0
66
66
  no-const-assign: 2
67
- no-continue: 1
67
+ no-continue: 0
68
68
  no-control-regex: 2
69
69
  no-debugger: 2
70
70
  no-delete-var: 2
package/HISTORY.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # History
2
2
 
3
+ ## 9.1.3
4
+
5
+ ### Bug fixes
6
+
7
+ * memory: eliminate memory leaks in `walk` and `eventify` (8592416af612795d08c5f9b50e8bf611cca303a5)
8
+ * match: remove vulnerable `jsonpath` dependency (d053e0de4cc91c1393b9377f0569eebf91eb84da)
9
+
10
+ ### Other changes
11
+
12
+ * repo: tweak lint config (efe9f392127d7ab845bd9c3becb87949cf0a4625)
13
+ * deps: `npm audit fix` (b4d066425e107288f8da3658d29f3e5ec57ad0c9)
14
+ * deps: upgrade `please-release-me` to `3.4.1` (cd65c37f626541599c3eef60ab0011055d304ae0)
15
+
3
16
  ## 9.1.2
4
17
 
5
18
  ### Bug fixes
package/README.md CHANGED
@@ -276,8 +276,13 @@ the comparison will be made
276
276
  by calling the [RegExp `test` method][regexp-test]
277
277
  with the property key.
278
278
  If it is a JSONPath expression,
279
- it must start with `$.` to identify the root node
280
- and only use `child` scope expressions for subsequent nodes.
279
+ it must start with `$.`
280
+ and supports only simple child access:
281
+ dot-notation properties (`$.foo.bar`),
282
+ bracket-notation strings (`$["foo"]`),
283
+ numeric indices (`$[0]`),
284
+ and wildcards (`$[*]`).
285
+ Filter, script, and recursive descent expressions are not supported.
281
286
  Predicate functions will be called with three arguments:
282
287
  `key`, `value` and `depth`.
283
288
  If the result of the predicate is a truthy value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bfj",
3
- "version": "9.1.2",
3
+ "version": "9.1.3",
4
4
  "description": "Big-friendly JSON. Asynchronous streaming functions for large JSON data sets.",
5
5
  "homepage": "https://gitlab.com/philbooth/bfj",
6
6
  "bugs": "https://gitlab.com/philbooth/bfj/issues",
@@ -31,7 +31,6 @@
31
31
  "dependencies": {
32
32
  "check-types": "^11.2.3",
33
33
  "hoopy": "^0.1.4",
34
- "jsonpath": "^1.1.1",
35
34
  "tryer": "^1.0.1"
36
35
  },
37
36
  "devDependencies": {
@@ -39,16 +38,17 @@
39
38
  "chai": "^4.5.0",
40
39
  "eslint": "^8.57.1",
41
40
  "mocha": "^10.7.3",
42
- "please-release-me": "^2.1.6",
41
+ "please-release-me": "^3.4.1",
43
42
  "proxyquire": "^2.1.3",
44
43
  "spooks": "^2.0.0"
45
44
  },
46
45
  "scripts": {
47
46
  "lint": "eslint src",
48
- "test": "npm run unit && npm run integration",
47
+ "test": "npm run unit && npm run integration && npm run memory",
49
48
  "unit": "mocha --ui tdd --reporter spec --recursive --colors --slow 120 test/unit",
50
49
  "integration": "mocha --ui tdd --reporter spec --colors test/integration",
51
- "perf": "wget -O test/mtg.json https://mtgjson.com/api/v5/AllPrices.json && node test/performance mtg",
52
- "perf-match": "wget -O test/mtg.json https://mtgjson.com/api/v5/AllPrices.json && node test/performance mtg currency"
50
+ "memory": "mocha --ui tdd --reporter spec --colors test/memory",
51
+ "perf": "test -f test/mtg.json || curl -o test/mtg.json https://mtgjson.com/api/v5/AllPrices.json && node test/performance mtg",
52
+ "perf-match": "test -f test/mtg.json || curl -o test/mtg.json https://mtgjson.com/api/v5/AllPrices.json && node test/performance mtg currency"
53
53
  }
54
54
  }
package/src/eventify.js CHANGED
@@ -249,7 +249,7 @@ function eventify (data, options = {}) {
249
249
 
250
250
  await emit(events[type])
251
251
 
252
- await item(obj, arr, type, action, ignoreThisItem, 0)
252
+ await item(obj, arr, type, action, ignoreThisItem)
253
253
  }
254
254
 
255
255
  async function emit (event, eventData) {
@@ -265,30 +265,26 @@ function eventify (data, options = {}) {
265
265
  }
266
266
  }
267
267
 
268
- async function item (obj, arr, type, action, ignoreThisItem, index) {
269
- if (index >= arr.length) {
270
- if (ignoreThisItem) {
271
- ignoreItems = false
272
- }
273
-
268
+ async function item (obj, arr, type, action, ignoreThisItem) {
269
+ for (let index = 0; index < arr.length; index++) {
274
270
  if (ignoreItems) {
275
- return
271
+ break
276
272
  }
277
273
 
278
- await emit(events.endPrefix + events[type])
279
-
280
- references.delete(obj)
274
+ await action(arr[index])
275
+ }
281
276
 
282
- return
277
+ if (ignoreThisItem) {
278
+ ignoreItems = false
283
279
  }
284
280
 
285
281
  if (ignoreItems) {
286
- return item(obj, arr, type, action, ignoreThisItem, index + 1)
282
+ return
287
283
  }
288
284
 
289
- await action(arr[index])
285
+ await emit(events.endPrefix + events[type])
290
286
 
291
- await item(obj, arr, type, action, ignoreThisItem, index + 1)
287
+ references.delete(obj)
292
288
  }
293
289
 
294
290
  function value (datum, type) {
@@ -0,0 +1,151 @@
1
+ 'use strict'
2
+
3
+ module.exports = parseJsonPath
4
+
5
+ /* Minimal jsonpath parser,
6
+ * replacing `jsonpath` which depended on `static-eval`
7
+ * (CVE-2026-1615, arbitrary code execution from untrusted input).
8
+ *
9
+ * Only the subset used by bfj is supported:
10
+ * $.foo.bar dot-notation properties
11
+ * $["foo"] bracket-notation strings
12
+ * $[0] numeric indices
13
+ * $[*] wildcards
14
+ *
15
+ * Filter expressions, script expressions, recursive descent, slices, and negative indices
16
+ * are all rejected.
17
+ * No eval, no regex, no dynamic code.
18
+ */
19
+
20
+ function parseJsonPath (selector) {
21
+ if (typeof selector !== 'string' || selector.length === 0 || selector[0] !== '$') {
22
+ throw new SyntaxError('Invalid jsonpath: must start with $')
23
+ }
24
+
25
+ const result = [ { expression: { type: 'root', value: '$' } } ]
26
+ let position = 1
27
+
28
+ if (position >= selector.length) {
29
+ throw new SyntaxError('Invalid jsonpath: must have at least one segment after $')
30
+ }
31
+
32
+ while (position < selector.length) {
33
+ const character = selector[position]
34
+
35
+ if (character === '.') {
36
+ position += 1
37
+ if (position >= selector.length || selector[position] === '.') {
38
+ throw new SyntaxError('Invalid jsonpath: unexpected character after dot')
39
+ }
40
+
41
+ const id = parseIdentifier(selector, position)
42
+ result.push({ expression: { type: 'identifier', value: id.value }, operation: 'member', scope: 'child' })
43
+ position = id.position
44
+ } else if (character === '[') {
45
+ position += 1
46
+ const content = parseBracketContent(selector, position)
47
+ result.push({ expression: content.expression, operation: 'subscript', scope: 'child' })
48
+
49
+ position = content.position
50
+ if (position >= selector.length || selector[position] !== ']') {
51
+ throw new SyntaxError('Invalid jsonpath: unterminated bracket')
52
+ }
53
+
54
+ position += 1
55
+ } else {
56
+ throw new SyntaxError(`Invalid jsonpath: unexpected character "${character}"`)
57
+ }
58
+ }
59
+
60
+ return result
61
+ }
62
+
63
+ function parseBracketContent (selector, position) {
64
+ if (position >= selector.length) {
65
+ throw new SyntaxError('Invalid jsonpath: unterminated bracket')
66
+ }
67
+
68
+ const character = selector[position]
69
+
70
+ if (character === '*') {
71
+ return { expression: { type: 'wildcard', value: '*' }, position: position + 1 }
72
+ }
73
+
74
+ if (character === '"' || character === "'") {
75
+ const str = parseStringLiteral(selector, position)
76
+ return { expression: { type: 'string_literal', value: str.value }, position: str.position }
77
+ }
78
+
79
+ if (isDigit(character)) {
80
+ const num = parseNumericLiteral(selector, position)
81
+ return { expression: { type: 'numeric_literal', value: num.value }, position: num.position }
82
+ }
83
+
84
+ throw new SyntaxError(`Invalid jsonpath: unexpected bracket content "${character}"`)
85
+ }
86
+
87
+ function parseIdentifier (selector, position) {
88
+ const start = position
89
+
90
+ if (position >= selector.length || !isIdentifierStart(selector[position])) {
91
+ throw new SyntaxError('Invalid jsonpath: expected identifier')
92
+ }
93
+
94
+ position += 1
95
+
96
+ while (position < selector.length && isIdentifier(selector[position])) {
97
+ position += 1
98
+ }
99
+
100
+ return { value: selector.slice(start, position), position }
101
+ }
102
+
103
+ function parseNumericLiteral (selector, position) {
104
+ const start = position
105
+
106
+ while (position < selector.length && isDigit(selector[position])) {
107
+ position += 1
108
+ }
109
+
110
+ return { value: parseInt(selector.slice(start, position), 10), position }
111
+ }
112
+
113
+ function parseStringLiteral (selector, position) {
114
+ const quote = selector[position]
115
+ position += 1
116
+ const start = position
117
+
118
+ while (position < selector.length && selector[position] !== quote) {
119
+ if (selector[position] === '\\') {
120
+ throw new SyntaxError('Invalid jsonpath: escape sequences in strings are not supported')
121
+ }
122
+ position += 1
123
+ }
124
+
125
+ if (position >= selector.length) {
126
+ throw new SyntaxError('Invalid jsonpath: unterminated string')
127
+ }
128
+
129
+ const value = selector.slice(start, position)
130
+ position += 1
131
+ return { value, position }
132
+ }
133
+
134
+ function isDigit (character) {
135
+ return character >= '0' && character <= '9'
136
+ }
137
+
138
+ function isIdentifier (character) {
139
+ return (character >= 'a' && character <= 'z') ||
140
+ (character >= 'A' && character <= 'Z') ||
141
+ (character >= '0' && character <= '9') ||
142
+ character === '_' ||
143
+ character === '$'
144
+ }
145
+
146
+ function isIdentifierStart (character) {
147
+ return (character >= 'a' && character <= 'z') ||
148
+ (character >= 'A' && character <= 'Z') ||
149
+ character === '_' ||
150
+ character === '$'
151
+ }
package/src/match.js CHANGED
@@ -4,7 +4,7 @@ const check = require('check-types')
4
4
  const DataStream = require('./datastream')
5
5
  const events = require('./events')
6
6
  const Hoopy = require('hoopy')
7
- const jsonpath = require('jsonpath')
7
+ const parseJsonPath = require('./jsonpath')
8
8
  const { PassThrough } = require('node:stream')
9
9
  const walk = require('./walk')
10
10
 
@@ -84,7 +84,7 @@ function match (stream, selector, options = {}) {
84
84
  check.assert.nonEmptyString(selector)
85
85
 
86
86
  if (selector.startsWith('$.')) {
87
- selectorPath = jsonpath.parse(selector)
87
+ selectorPath = parseJsonPath(selector)
88
88
  check.assert.identical(selectorPath.shift(), {
89
89
  expression: {
90
90
  type: 'root',
package/src/walk.js CHANGED
@@ -57,10 +57,6 @@ function initialise (stream, options = {}) {
57
57
  column: 1
58
58
  }
59
59
  const emitter = new EventEmitter()
60
- const handlers = {
61
- arr: value,
62
- obj: property
63
- }
64
60
  const json = []
65
61
  const lengths = []
66
62
  const previousPosition = {}
@@ -116,7 +112,7 @@ function initialise (stream, options = {}) {
116
112
  resume()
117
113
  } else {
118
114
  isWalkBegun = true
119
- value()
115
+ topLevelLoop()
120
116
  }
121
117
  }
122
118
 
@@ -145,36 +141,97 @@ function initialise (stream, options = {}) {
145
141
  return lengths[chunkCount - 1].aggregate
146
142
  }
147
143
 
148
- function value () {
149
- if (++count % yieldRate !== 0) {
150
- return consumeValue()
144
+ async function topLevelLoop () {
145
+ try {
146
+ if (shouldHandleNdjson) {
147
+ while (true) {
148
+ await awaitNonWhitespace()
149
+
150
+ if (character() === '\n') {
151
+ hasEndedLine = true
152
+ await next()
153
+ await emit(events.endLine)
154
+ continue
155
+ }
156
+
157
+ if (! hasEndedLine) {
158
+ await fail(character(), '\n', currentPosition)
159
+ await next()
160
+ continue
161
+ }
162
+
163
+ hasEndedLine = false
164
+ await value()
165
+ }
166
+ } else {
167
+ await value()
168
+
169
+ while (true) {
170
+ await awaitNonWhitespace()
171
+ await fail(character(), 'EOF', currentPosition)
172
+ await value()
173
+ }
174
+ }
175
+ } catch (_) {
176
+ setImmediate(endWalk)
177
+ }
178
+ }
179
+
180
+ async function value () {
181
+ if (++count % yieldRate === 0) {
182
+ await new Promise(resolve => {
183
+ setImmediate(resolve)
184
+ })
151
185
  }
152
186
 
153
- return new Promise(resolve => {
154
- setImmediate(() => consumeValue(resolve))
155
- })
187
+ await parseOneValue()
156
188
  }
157
189
 
158
- async function consumeValue (resolve, reject) {
159
- try {
160
- await awaitNonWhitespace()
161
- await handleValue(await next())
162
- if (resolve) {
163
- resolve()
164
- }
165
- } catch (err) {
166
- if (reject) {
167
- reject(err)
168
- }
190
+ async function parseOneValue () {
191
+ await awaitNonWhitespace()
192
+ const char = await next()
193
+
194
+ switch (char) {
195
+ case '[':
196
+ return array()
197
+ case '{':
198
+ return object()
199
+ case '"':
200
+ return string()
201
+ case '0':
202
+ case '1':
203
+ case '2':
204
+ case '3':
205
+ case '4':
206
+ case '5':
207
+ case '6':
208
+ case '7':
209
+ case '8':
210
+ case '9':
211
+ case '-':
212
+ case '.':
213
+ return number(char)
214
+ case 'f':
215
+ return literalFalse()
216
+ case 'n':
217
+ return literalNull()
218
+ case 't':
219
+ return literalTrue()
220
+ default:
221
+ await fail(char, 'value', previousPosition)
222
+ return value()
169
223
  }
170
224
  }
171
225
 
172
226
  async function awaitNonWhitespace () {
173
- await awaitCharacter()
227
+ while (true) {
228
+ await awaitCharacter()
229
+
230
+ if (! isWhitespace(character())) {
231
+ return
232
+ }
174
233
 
175
- if (isWhitespace(character())) {
176
234
  await next()
177
- await awaitNonWhitespace()
178
235
  }
179
236
  }
180
237
 
@@ -272,131 +329,62 @@ function initialise (stream, options = {}) {
272
329
  return result
273
330
  }
274
331
 
275
- async function handleValue (char) {
276
- if (shouldHandleNdjson && scopes.length === 0) {
277
- if (char === '\n') {
278
- hasEndedLine = true
279
- await emit(events.endLine)
280
- return value()
281
- }
282
-
283
- if (! hasEndedLine) {
284
- await fail(char, '\n', previousPosition)
285
- return value()
286
- }
287
-
288
- hasEndedLine = false
289
- }
290
-
291
- switch (char) {
292
- case '[':
293
- return array()
294
- case '{':
295
- return object()
296
- case '"':
297
- return string()
298
- case '0':
299
- case '1':
300
- case '2':
301
- case '3':
302
- case '4':
303
- case '5':
304
- case '6':
305
- case '7':
306
- case '8':
307
- case '9':
308
- case '-':
309
- case '.':
310
- return number(char)
311
- case 'f':
312
- return literalFalse()
313
- case 'n':
314
- return literalNull()
315
- case 't':
316
- return literalTrue()
317
- default:
318
- await fail(char, 'value', previousPosition)
319
- return value()
320
- }
321
- }
322
-
323
332
  function array () {
324
333
  return scope(events.array, value)
325
334
  }
326
335
 
327
336
  async function scope (event, contentHandler) {
328
337
  await emit(event)
329
-
330
338
  scopes.push(event)
331
- await endScope(event)
332
-
333
- return await contentHandler()
334
- }
335
-
336
- async function emit (...args) {
337
- if (pause) {
338
- await pause
339
- }
340
-
341
- try {
342
- emitter.emit(...args)
343
- } catch (err) {
344
- try {
345
- emitter.emit(events.error, err)
346
- } catch (_) {
347
- // When calling user code, anything is possible
348
- }
349
- }
350
- }
351
339
 
352
- async function endScope (scp) {
353
340
  try {
354
341
  await awaitNonWhitespace()
355
342
 
356
- if (character() === terminators[scp]) {
357
- await emit(events.endPrefix + scp)
358
-
343
+ if (character() === terminators[event]) {
344
+ await emit(events.endPrefix + event)
359
345
  scopes.pop()
360
346
  await next()
361
-
362
- await endValue()
347
+ return
363
348
  }
364
- } catch (_) {
365
- setImmediate(endWalk)
366
- }
367
- }
368
349
 
369
- async function endValue () {
370
- try {
371
- await awaitNonWhitespace()
350
+ while (true) {
351
+ await contentHandler()
372
352
 
373
- if (scopes.length === 0) {
374
- if (shouldHandleNdjson) {
375
- await value()
376
- } else {
377
- await fail(character(), 'EOF', currentPosition)
378
- await value()
379
- }
380
- }
381
-
382
- const scp = scopes[scopes.length - 1]
383
- const handler = handlers[scp]
353
+ await awaitNonWhitespace()
384
354
 
385
- await endScope(scp)
355
+ if (character() === terminators[event]) {
356
+ await emit(events.endPrefix + event)
357
+ scopes.pop()
358
+ await next()
359
+ return
360
+ }
386
361
 
387
- if (scopes.length > 0) {
388
362
  const isComma = await checkCharacter(character(), ',', currentPosition)
389
363
  if (isComma) {
390
364
  await next()
391
365
  }
392
366
  }
393
-
394
- await handler()
395
367
  } catch (_) {
396
368
  setImmediate(endWalk)
397
369
  }
398
370
  }
399
371
 
372
+ async function emit (...args) {
373
+ if (pause) {
374
+ await pause
375
+ }
376
+
377
+ try {
378
+ emitter.emit(...args)
379
+ } catch (err) {
380
+ try {
381
+ emitter.emit(events.error, err)
382
+ } catch (_) {
383
+ // When calling user code, anything is possible
384
+ }
385
+ }
386
+ }
387
+
400
388
  function fail (actual, expected, position) {
401
389
  return emit(
402
390
  events.dataError,
@@ -434,30 +422,35 @@ function initialise (stream, options = {}) {
434
422
 
435
423
  async function walkString (event) {
436
424
  isWalkingString = true
437
- await walkStringContinue(event, [], false)
438
- }
439
425
 
440
- async function walkStringContinue (event, str, isEscaping) {
441
- const char = await next()
426
+ const str = []
427
+ let escaping = false
442
428
 
443
- if (isEscaping) {
444
- str.push(await escape(char))
445
- if (++stringChunkCount >= stringChunkSize) {
446
- await walkStringChunk(event, str)
429
+ while (true) {
430
+ const char = await next()
431
+
432
+ if (escaping) {
433
+ str.push(await escape(char))
434
+ if (++stringChunkCount >= stringChunkSize) {
435
+ await walkStringChunk(event, str)
436
+ }
437
+ escaping = false
438
+ continue
447
439
  }
448
- return walkStringContinue(event, str, false)
449
- }
450
440
 
451
- if (char === '\\') {
452
- return walkStringContinue(event, str, true)
453
- }
441
+ if (char === '\\') {
442
+ escaping = true
443
+ continue
444
+ }
445
+
446
+ if (char === '"') {
447
+ break
448
+ }
454
449
 
455
- if (char !== '"') {
456
450
  str.push(char)
457
451
  if (++stringChunkCount >= stringChunkSize) {
458
452
  await walkStringChunk(event, str)
459
453
  }
460
- return walkStringContinue(event, str, isEscaping)
461
454
  }
462
455
 
463
456
  isWalkingString = false
@@ -465,7 +458,7 @@ function initialise (stream, options = {}) {
465
458
  await walkStringChunk(event, str)
466
459
  stringChunkStart = 0
467
460
 
468
- return emit(event, str.join(''))
461
+ await emit(event, str.join(''))
469
462
  }
470
463
 
471
464
  function walkStringChunk (event, str) {
@@ -483,7 +476,7 @@ function initialise (stream, options = {}) {
483
476
  }
484
477
 
485
478
  if (char === 'u') {
486
- return escapeHex([], 0)
479
+ return escapeHex()
487
480
  }
488
481
 
489
482
  await fail(char, 'escape character', previousPosition)
@@ -491,30 +484,25 @@ function initialise (stream, options = {}) {
491
484
  return `\\${char}`
492
485
  }
493
486
 
494
- async function escapeHex (hexits, idx) {
495
- const char = await next()
496
-
497
- if (isHexit(char)) {
498
- hexits.push(char)
499
- }
487
+ async function escapeHex () {
488
+ const hexits = []
500
489
 
501
- if (idx < 3) {
502
- return escapeHex(hexits, idx + 1)
503
- }
490
+ for (let i = 0; i < 4; i++) {
491
+ const char = await next()
504
492
 
505
- hexits = hexits.join('')
493
+ if (! isHexit(char)) {
494
+ await fail(char, 'hex digit', previousPosition)
495
+ return `\\u${hexits.join('')}${char}`
496
+ }
506
497
 
507
- if (hexits.length === 4) {
508
- return String.fromCharCode(parseInt(hexits, 16))
498
+ hexits.push(char)
509
499
  }
510
500
 
511
- await fail(char, 'hex digit', previousPosition)
512
- return `\\u${hexits}${char}`
501
+ return String.fromCharCode(parseInt(hexits.join(''), 16))
513
502
  }
514
503
 
515
504
  async function string () {
516
505
  await walkString(events.string)
517
- await endValue()
518
506
  }
519
507
 
520
508
  async function number (firstCharacter) {
@@ -553,14 +541,15 @@ function initialise (stream, options = {}) {
553
541
 
554
542
  async function walkDigits (digits) {
555
543
  try {
556
- await awaitCharacter()
544
+ while (true) {
545
+ await awaitCharacter()
546
+
547
+ if (! isDigit(character())) {
548
+ return false
549
+ }
557
550
 
558
- if (isDigit(character())) {
559
551
  digits.push(await next())
560
- return walkDigits(digits)
561
552
  }
562
-
563
- return false
564
553
  } catch (_) {
565
554
  return true
566
555
  }
@@ -568,7 +557,6 @@ function initialise (stream, options = {}) {
568
557
 
569
558
  async function endNumber (digits) {
570
559
  await emit(events.number, parseFloat(digits.join('')))
571
- await endValue()
572
560
  }
573
561
 
574
562
  function literalFalse () {
@@ -576,32 +564,30 @@ function initialise (stream, options = {}) {
576
564
  }
577
565
 
578
566
  async function literal (expectedCharacters, val) {
579
- try {
580
- await awaitCharacter()
567
+ let consumed = 0
581
568
 
582
- const actual = await next()
583
- const expected = expectedCharacters.shift()
569
+ try {
570
+ for (; consumed < expectedCharacters.length; consumed++) {
571
+ await awaitCharacter()
584
572
 
585
- if (actual !== expected) {
586
- // eslint-disable-next-line no-throw-literal
587
- throw [ actual, expected ]
588
- }
573
+ const actual = await next()
574
+ const expected = expectedCharacters[consumed]
589
575
 
590
- if (expectedCharacters.length === 0) {
591
- await emit(events.literal, val)
592
- return endValue()
576
+ if (actual !== expected) {
577
+ if (consumed < expectedCharacters.length - 1) {
578
+ await fail('EOF', expectedCharacters[consumed + 1], currentPosition)
579
+ } else {
580
+ await fail(actual, expected, previousPosition)
581
+ }
582
+ return
583
+ }
593
584
  }
594
585
 
595
- await literal(expectedCharacters, val)
596
- } catch (err) {
597
- if (expectedCharacters.length > 0) {
598
- await fail('EOF', expectedCharacters[0], currentPosition)
599
- } else if (Array.isArray(err)) {
600
- const [ actual, expected ] = err
601
- await fail(actual, expected, previousPosition)
586
+ await emit(events.literal, val)
587
+ } catch (_) {
588
+ if (consumed < expectedCharacters.length) {
589
+ await fail('EOF', expectedCharacters[consumed], currentPosition)
602
590
  }
603
-
604
- return endValue()
605
591
  }
606
592
  }
607
593
 
@@ -646,12 +632,9 @@ function initialise (stream, options = {}) {
646
632
  }
647
633
 
648
634
  async function popScopes () {
649
- if (scopes.length === 0) {
650
- return
635
+ while (scopes.length > 0) {
636
+ await fail('EOF', terminators[scopes.pop()], currentPosition)
651
637
  }
652
-
653
- await fail('EOF', terminators[scopes.pop()], currentPosition)
654
- await popScopes()
655
638
  }
656
639
  }
657
640
 
package/test/memory.js ADDED
@@ -0,0 +1,56 @@
1
+ 'use strict'
2
+
3
+ const assert = require('chai').assert
4
+ const { Readable } = require('stream')
5
+
6
+ const bfj = require('../src')
7
+
8
+ const ELEMENT_COUNT = 1_000_000
9
+
10
+ suite('memory:', () => {
11
+ test('walk does not OOM on large array', function (done) {
12
+ this.timeout(120_000)
13
+
14
+ const json = `[${new Array(ELEMENT_COUNT).fill('1').join(',')}]`
15
+ const stream = new Readable()
16
+ stream._read = () => {}
17
+ stream.push(json)
18
+ stream.push(null)
19
+
20
+ let count = 0
21
+ const baselineRss = process.memoryUsage().rss
22
+ const emitter = bfj.walk(stream)
23
+
24
+ emitter.on('num', () => { count++ })
25
+ emitter.on('err', done)
26
+ emitter.on('err-data', (err) => done(err))
27
+ emitter.on('end', () => {
28
+ const peakRss = process.memoryUsage().rss
29
+ const growth = peakRss - baselineRss
30
+ assert.strictEqual(count, ELEMENT_COUNT)
31
+ assert.isBelow(growth, 512 * 1024 * 1024, `RSS grew by ${(growth / 1024 / 1024).toFixed(0)} MB`)
32
+ done()
33
+ })
34
+ })
35
+
36
+ test('eventify does not OOM on large array', function (done) {
37
+ this.timeout(120_000)
38
+
39
+ const data = new Array(ELEMENT_COUNT).fill(1)
40
+
41
+ let count = 0
42
+ const baselineRss = process.memoryUsage().rss
43
+ const emitter = bfj.eventify(data)
44
+
45
+ emitter.on('num', () => { count++ })
46
+ emitter.on('err', done)
47
+ emitter.on('err-data', (err) => done(err))
48
+ emitter.on('end', () => {
49
+ const peakRss = process.memoryUsage().rss
50
+ const growth = peakRss - baselineRss
51
+ assert.strictEqual(count, ELEMENT_COUNT)
52
+ assert.isBelow(growth, 512 * 1024 * 1024, `RSS grew by ${(growth / 1024 / 1024).toFixed(0)} MB`)
53
+ done()
54
+ })
55
+ })
56
+ })
@@ -0,0 +1,249 @@
1
+ 'use strict'
2
+
3
+ const assert = require('chai').assert
4
+
5
+ const modulePath = '../../src/jsonpath'
6
+
7
+ suite('jsonpath:', () => {
8
+ let parseJsonPath
9
+
10
+ setup(() => {
11
+ parseJsonPath = require(modulePath)
12
+ })
13
+
14
+ test('require does not throw', () => {
15
+ assert.doesNotThrow(() => require(modulePath))
16
+ })
17
+
18
+ test('require returns function', () => {
19
+ assert.isFunction(require(modulePath))
20
+ })
21
+
22
+ suite('valid paths:', () => {
23
+ test('$.foo returns member identifier', () => {
24
+ assert.deepEqual(parseJsonPath('$.foo'), [
25
+ { expression: { type: 'root', value: '$' } },
26
+ { expression: { type: 'identifier', value: 'foo' }, operation: 'member', scope: 'child' },
27
+ ])
28
+ })
29
+
30
+ test('$.foo.bar returns chained members', () => {
31
+ assert.deepEqual(parseJsonPath('$.foo.bar'), [
32
+ { expression: { type: 'root', value: '$' } },
33
+ { expression: { type: 'identifier', value: 'foo' }, operation: 'member', scope: 'child' },
34
+ { expression: { type: 'identifier', value: 'bar' }, operation: 'member', scope: 'child' },
35
+ ])
36
+ })
37
+
38
+ test('$[0] returns numeric subscript', () => {
39
+ assert.deepEqual(parseJsonPath('$[0]'), [
40
+ { expression: { type: 'root', value: '$' } },
41
+ { expression: { type: 'numeric_literal', value: 0 }, operation: 'subscript', scope: 'child' },
42
+ ])
43
+ })
44
+
45
+ test('$[42] returns numeric subscript', () => {
46
+ assert.deepEqual(parseJsonPath('$[42]'), [
47
+ { expression: { type: 'root', value: '$' } },
48
+ { expression: { type: 'numeric_literal', value: 42 }, operation: 'subscript', scope: 'child' },
49
+ ])
50
+ })
51
+
52
+ test('$["foo"] returns string subscript with double quotes', () => {
53
+ assert.deepEqual(parseJsonPath('$["foo"]'), [
54
+ { expression: { type: 'root', value: '$' } },
55
+ { expression: { type: 'string_literal', value: 'foo' }, operation: 'subscript', scope: 'child' },
56
+ ])
57
+ })
58
+
59
+ test("$['foo'] returns string subscript with single quotes", () => {
60
+ assert.deepEqual(parseJsonPath("$['foo']"), [
61
+ { expression: { type: 'root', value: '$' } },
62
+ { expression: { type: 'string_literal', value: 'foo' }, operation: 'subscript', scope: 'child' },
63
+ ])
64
+ })
65
+
66
+ test('$[*] returns wildcard subscript', () => {
67
+ assert.deepEqual(parseJsonPath('$[*]'), [
68
+ { expression: { type: 'root', value: '$' } },
69
+ { expression: { type: 'wildcard', value: '*' }, operation: 'subscript', scope: 'child' },
70
+ ])
71
+ })
72
+
73
+ test('$.foo.bar[*] returns mixed path', () => {
74
+ assert.deepEqual(parseJsonPath('$.foo.bar[*]'), [
75
+ { expression: { type: 'root', value: '$' } },
76
+ { expression: { type: 'identifier', value: 'foo' }, operation: 'member', scope: 'child' },
77
+ { expression: { type: 'identifier', value: 'bar' }, operation: 'member', scope: 'child' },
78
+ { expression: { type: 'wildcard', value: '*' }, operation: 'subscript', scope: 'child' },
79
+ ])
80
+ })
81
+
82
+ test('$._private returns identifier with underscore prefix', () => {
83
+ assert.deepEqual(parseJsonPath('$._private'), [
84
+ { expression: { type: 'root', value: '$' } },
85
+ { expression: { type: 'identifier', value: '_private' }, operation: 'member', scope: 'child' },
86
+ ])
87
+ })
88
+
89
+ test('$.$ref returns identifier with dollar prefix', () => {
90
+ assert.deepEqual(parseJsonPath('$.$ref'), [
91
+ { expression: { type: 'root', value: '$' } },
92
+ { expression: { type: 'identifier', value: '$ref' }, operation: 'member', scope: 'child' },
93
+ ])
94
+ })
95
+
96
+ test('$["foo"].bar returns bracket then dot chaining', () => {
97
+ assert.deepEqual(parseJsonPath('$["foo"].bar'), [
98
+ { expression: { type: 'root', value: '$' } },
99
+ { expression: { type: 'string_literal', value: 'foo' }, operation: 'subscript', scope: 'child' },
100
+ { expression: { type: 'identifier', value: 'bar' }, operation: 'member', scope: 'child' },
101
+ ])
102
+ })
103
+
104
+ test('$[0][1] returns consecutive brackets', () => {
105
+ assert.deepEqual(parseJsonPath('$[0][1]'), [
106
+ { expression: { type: 'root', value: '$' } },
107
+ { expression: { type: 'numeric_literal', value: 0 }, operation: 'subscript', scope: 'child' },
108
+ { expression: { type: 'numeric_literal', value: 1 }, operation: 'subscript', scope: 'child' },
109
+ ])
110
+ })
111
+
112
+ test('$[0].foo[*] returns bracket-dot-bracket chaining', () => {
113
+ assert.deepEqual(parseJsonPath('$[0].foo[*]'), [
114
+ { expression: { type: 'root', value: '$' } },
115
+ { expression: { type: 'numeric_literal', value: 0 }, operation: 'subscript', scope: 'child' },
116
+ { expression: { type: 'identifier', value: 'foo' }, operation: 'member', scope: 'child' },
117
+ { expression: { type: 'wildcard', value: '*' }, operation: 'subscript', scope: 'child' },
118
+ ])
119
+ })
120
+ })
121
+
122
+ suite('rejection:', () => {
123
+ test('empty string throws', () => {
124
+ assert.throws(() => parseJsonPath(''), /Invalid jsonpath/)
125
+ })
126
+
127
+ test('$ alone throws', () => {
128
+ assert.throws(() => parseJsonPath('$'), /Invalid jsonpath/)
129
+ })
130
+
131
+ test('does not start with $ throws', () => {
132
+ assert.throws(() => parseJsonPath('foo.bar'), /Invalid jsonpath/)
133
+ })
134
+
135
+ test('$..foo recursive descent throws', () => {
136
+ assert.throws(() => parseJsonPath('$..foo'), /Invalid jsonpath/)
137
+ })
138
+
139
+ test('$.foo[?(@.bar)] filter expression throws', () => {
140
+ assert.throws(() => parseJsonPath('$.foo[?(@.bar)]'), /Invalid jsonpath/)
141
+ })
142
+
143
+ test('$[(@.length-1)] script expression throws', () => {
144
+ assert.throws(() => parseJsonPath('$[(@.length-1)]'), /Invalid jsonpath/)
145
+ })
146
+
147
+ test('$.foo[-1] negative index throws', () => {
148
+ assert.throws(() => parseJsonPath('$.foo[-1]'), /Invalid jsonpath/)
149
+ })
150
+
151
+ test('$.foo[0:5] slice throws', () => {
152
+ assert.throws(() => parseJsonPath('$.foo[0:5]'), /Invalid jsonpath/)
153
+ })
154
+
155
+ test('unterminated bracket throws', () => {
156
+ assert.throws(() => parseJsonPath('$.foo[0'), /Invalid jsonpath/)
157
+ })
158
+
159
+ test('unterminated string throws', () => {
160
+ assert.throws(() => parseJsonPath('$.foo["bar'), /Invalid jsonpath/)
161
+ })
162
+
163
+ test('$.123 identifier starting with digit throws', () => {
164
+ assert.throws(() => parseJsonPath('$.123'), /Invalid jsonpath/)
165
+ })
166
+
167
+ test('backslash escape in double-quoted string throws', () => {
168
+ assert.throws(() => parseJsonPath('$["foo\\"bar"]'), /Invalid jsonpath/)
169
+ })
170
+
171
+ test('backslash escape in single-quoted string throws', () => {
172
+ assert.throws(() => parseJsonPath("$['it\\'s']"), /Invalid jsonpath/)
173
+ })
174
+ })
175
+
176
+ suite('security:', () => {
177
+ test('prototype traversal RCE parses without code execution', () => {
178
+ assert.throws(() => parseJsonPath('$[?(@.constructor.constructor("return process")().exit())]'))
179
+ })
180
+
181
+ test('$.__proto__ parses to inert AST without code execution', () => {
182
+ const result = parseJsonPath('$.__proto__')
183
+ assert.deepEqual(result, [
184
+ { expression: { type: 'root', value: '$' } },
185
+ { expression: { type: 'identifier', value: '__proto__' }, operation: 'member', scope: 'child' },
186
+ ])
187
+ })
188
+
189
+ test('$["__proto__"] parses to inert AST without code execution', () => {
190
+ const result = parseJsonPath('$["__proto__"]')
191
+ assert.deepEqual(result, [
192
+ { expression: { type: 'root', value: '$' } },
193
+ { expression: { type: 'string_literal', value: '__proto__' }, operation: 'subscript', scope: 'child' },
194
+ ])
195
+ })
196
+
197
+ test('$.constructor parses to inert AST without code execution', () => {
198
+ const result = parseJsonPath('$.constructor')
199
+ assert.deepEqual(result, [
200
+ { expression: { type: 'root', value: '$' } },
201
+ { expression: { type: 'identifier', value: 'constructor' }, operation: 'member', scope: 'child' },
202
+ ])
203
+ })
204
+
205
+ test('$["constructor"] parses to inert AST without code execution', () => {
206
+ const result = parseJsonPath('$["constructor"]')
207
+ assert.deepEqual(result, [
208
+ { expression: { type: 'root', value: '$' } },
209
+ { expression: { type: 'string_literal', value: 'constructor' }, operation: 'subscript', scope: 'child' },
210
+ ])
211
+ })
212
+
213
+ test('$.prototype parses to inert AST without code execution', () => {
214
+ const result = parseJsonPath('$.prototype')
215
+ assert.deepEqual(result, [
216
+ { expression: { type: 'root', value: '$' } },
217
+ { expression: { type: 'identifier', value: 'prototype' }, operation: 'member', scope: 'child' },
218
+ ])
219
+ })
220
+
221
+ test('eval injection in filter throws', () => {
222
+ assert.throws(() => parseJsonPath('$[?(@.eval("process.exit()"))]'))
223
+ })
224
+
225
+ test('semicolon expression escape throws', () => {
226
+ assert.throws(() => parseJsonPath('$.foo; process.exit()'))
227
+ })
228
+
229
+ test('newline expression escape throws', () => {
230
+ assert.throws(() => parseJsonPath('$.foo\nprocess.exit()'))
231
+ })
232
+
233
+ test('CRLF injection throws', () => {
234
+ assert.throws(() => parseJsonPath("$.foo\r\nrequire('child_process').exec('rm -rf /')"))
235
+ })
236
+
237
+ test('Function constructor in bracket throws', () => {
238
+ assert.throws(() => parseJsonPath('$[new Function("return process")()]'))
239
+ })
240
+
241
+ test('eval in bracket throws', () => {
242
+ assert.throws(() => parseJsonPath('$[eval("1")]'))
243
+ })
244
+
245
+ test('bracket injection attempt throws', () => {
246
+ assert.throws(() => parseJsonPath('$.foo](malicious)[bar'))
247
+ })
248
+ })
249
+ })
package/test/unit/walk.js CHANGED
@@ -1054,6 +1054,50 @@ suite('walk:', () => {
1054
1054
  })
1055
1055
  })
1056
1056
 
1057
+ suite('string containing early bad unicode escape sequence:', () => {
1058
+ let stream, emitter
1059
+
1060
+ setup(done => {
1061
+ stream = new Readable()
1062
+ stream._read = () => {}
1063
+
1064
+ emitter = walk(stream)
1065
+
1066
+ stream.push('"\\u0Gxx"')
1067
+ stream.push(null)
1068
+
1069
+ Object.entries(events).forEach(([ key, value ]) => {
1070
+ emitter.on(value, spooks.fn({
1071
+ name: key,
1072
+ log: log
1073
+ }))
1074
+ })
1075
+
1076
+ emitter.on(events.end, done)
1077
+ })
1078
+
1079
+ test('dataError event occurred once', () => {
1080
+ assert.strictEqual(log.counts.dataError, 1)
1081
+ })
1082
+
1083
+ test('dataError event was dispatched correctly', () => {
1084
+ assert.strictEqual(log.args.dataError[0][0].actual, 'G')
1085
+ assert.strictEqual(log.args.dataError[0][0].expected, 'hex digit')
1086
+ })
1087
+
1088
+ test('string event occurred once', () => {
1089
+ assert.strictEqual(log.counts.string, 1)
1090
+ })
1091
+
1092
+ test('string event was dispatched correctly', () => {
1093
+ assert.strictEqual(log.args.string[0][0], '\\u0Gxx')
1094
+ })
1095
+
1096
+ test('end event occurred once', () => {
1097
+ assert.strictEqual(log.counts.end, 1)
1098
+ })
1099
+ })
1100
+
1057
1101
  suite('unterminated string:', () => {
1058
1102
  let stream, emitter
1059
1103