@jarenjs/josl 0.67.0 → 0.72.2
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.
- package/README.md +16 -8
- package/dist/types/csv-machine.d.ts +4 -1
- package/dist/types/jsonx-stream.d.ts +2 -0
- package/dist/types/limits.d.ts +8 -0
- package/dist/types/machine.d.ts +6 -0
- package/package.json +2 -2
- package/src/csv-machine.js +33 -32
- package/src/csv.js +2 -1
- package/src/jsonx-stream.js +16 -11
- package/src/limits.js +24 -1
- package/src/machine.js +100 -14
package/README.md
CHANGED
|
@@ -147,7 +147,10 @@ for await (const record of iterateJoslStream(response.body, { signal }))
|
|
|
147
147
|
Every reader refuses a document by size only when asked: the limits
|
|
148
148
|
default to `Infinity`, count **UTF-8 bytes** (never code units), and are
|
|
149
149
|
checked while the offending text is still in cutter, token or container
|
|
150
|
-
state
|
|
150
|
+
state. Limited streams inspect bounded slices and reject an oversized pending
|
|
151
|
+
span before accepting another slice; complete spans are checked before decoding
|
|
152
|
+
or linking values. Total bytes include a leading BOM, and splitting a surrogate
|
|
153
|
+
pair across chunks does not change the byte count. A crossing
|
|
151
154
|
throws `JoslLimitError` with a stable code and the limit; it is never a
|
|
152
155
|
repair, because a document that is too large is not damaged.
|
|
153
156
|
|
|
@@ -159,7 +162,7 @@ repair, because a document that is too large is not damaged.
|
|
|
159
162
|
| CSV | `maxColumns` | `CSV2004` |
|
|
160
163
|
| JOSL / TOML | `maxTotalBytes` | `JOSL2001` |
|
|
161
164
|
| JOSL / TOML | `maxRecordBytes` (one logical line) | `JOSL2002` |
|
|
162
|
-
| JOSL / TOML | `maxTokenBytes` (a
|
|
165
|
+
| JOSL / TOML | `maxTokenBytes` (a key or scalar token as written, quotes and regexp flags included) | `JOSL2003` |
|
|
163
166
|
| JOSL / TOML | `maxDepth` (inline nesting, header path depth) | `JOSL2004` |
|
|
164
167
|
| JOSL / TOML | `maxRetainedValues` (values the root holds; starts over per detached `[[]]` item) | `JOSL2005` |
|
|
165
168
|
| JSONX / JSON stream | `maxTotalBytes` | `JSONX2001` |
|
|
@@ -172,6 +175,11 @@ the same machines; the JSONX limits are the stream reader's, which is
|
|
|
172
175
|
where a document arrives a chunk at a time. `CSV_LIMIT_CODES`,
|
|
173
176
|
`JOSL_LIMIT_CODES` and `JSONX_LIMIT_CODES` are the tables as data.
|
|
174
177
|
|
|
178
|
+
Field and token limits bound those spans, not an entire record containing many
|
|
179
|
+
small spans. Use `maxRecordBytes` or `maxTotalBytes` as well when the buffered
|
|
180
|
+
source itself needs a hard ceiling. Open strings, bare keys and scalar tokens
|
|
181
|
+
are checked as they arrive, including in the first chunk.
|
|
182
|
+
|
|
175
183
|
Measured on a synthetic OpenStreetMap-shaped extract (`node --expose-gc
|
|
176
184
|
benchmark/jsonx-stream.js`), reading 20 000 features of 40 vertices each:
|
|
177
185
|
|
|
@@ -457,12 +465,12 @@ acceptance corpus, and on a scorecard of damaged documents.
|
|
|
457
465
|
<!--fact:csv.table-->
|
|
458
466
|
| engine | csv-spectrum | 10k×6 plain | 10k×3 quoted | 1k×50 wide |
|
|
459
467
|
| --- | --- | --- | --- | --- |
|
|
460
|
-
| **jaren** | **11/11** | 2.
|
|
461
|
-
| udsv | 11/11 | **1.
|
|
462
|
-
| papaparse | 11/11 |
|
|
463
|
-
| csv-parse | 11/11 |
|
|
464
|
-
| d3-dsv | 11/11 | 2.
|
|
465
|
-
| @vanillaes/csv | n/a |
|
|
468
|
+
| **jaren** | **11/11** | 2.2 ms | 3.0 ms | 1.3 ms |
|
|
469
|
+
| udsv | 11/11 | **1.8 ms** | **2.9 ms** | **1.0 ms** |
|
|
470
|
+
| papaparse | 11/11 | 5.8 ms | 7.5 ms | 2.1 ms |
|
|
471
|
+
| csv-parse | 11/11 | 17.7 ms | 11.9 ms | 11.0 ms |
|
|
472
|
+
| d3-dsv | 11/11 | 2.9 ms | 3.9 ms | 1.7 ms |
|
|
473
|
+
| @vanillaes/csv | n/a | 5.9 ms | 7.3 ms | 4.6 ms |
|
|
466
474
|
<!--/fact-->
|
|
467
475
|
|
|
468
476
|
(The suite's twelfth fixture, `location_coordinates`, is excluded: its
|
|
@@ -45,6 +45,8 @@ export declare class CsvMachine {
|
|
|
45
45
|
maxColumns: number;
|
|
46
46
|
limited: boolean;
|
|
47
47
|
totalBytes: number;
|
|
48
|
+
byteTail: any;
|
|
49
|
+
scanFieldStart: number;
|
|
48
50
|
wantHeader: boolean;
|
|
49
51
|
headerFields: any;
|
|
50
52
|
objectRows: boolean;
|
|
@@ -76,7 +78,6 @@ export declare class CsvMachine {
|
|
|
76
78
|
*/
|
|
77
79
|
feed(chunk: string): this;
|
|
78
80
|
count(text: any): void;
|
|
79
|
-
endsRecordWithin(text: any): boolean;
|
|
80
81
|
/**
|
|
81
82
|
* Finish the document, flushing any pending record.
|
|
82
83
|
* @returns {Array} The completed rows
|
|
@@ -101,6 +102,8 @@ export declare class CsvMachine {
|
|
|
101
102
|
heal(code: any, line: any, column: any, detail?: undefined): void;
|
|
102
103
|
scan(): void;
|
|
103
104
|
compact(start: any): void;
|
|
105
|
+
/** Check a field span while its record is still incomplete. */
|
|
106
|
+
checkFieldBytes(text: any, start: any, end: any, recordStart?: number): void;
|
|
104
107
|
readSpan(text: any, pos: any, end: any): any;
|
|
105
108
|
admitCell(cells: any, text: any, start: any, stop: any): void;
|
|
106
109
|
parseRecord(text: any, pos: any, end: any, cells: any): any;
|
|
@@ -8,6 +8,7 @@ export declare class JsonxMachine {
|
|
|
8
8
|
maxDepth: number;
|
|
9
9
|
maxRetainedValues: number;
|
|
10
10
|
totalBytes: number;
|
|
11
|
+
byteTail: any;
|
|
11
12
|
retained: number;
|
|
12
13
|
partialFrom: number;
|
|
13
14
|
partialHold: string;
|
|
@@ -21,6 +22,7 @@ export declare class JsonxMachine {
|
|
|
21
22
|
curLine: number;
|
|
22
23
|
lineStart: number;
|
|
23
24
|
scanPos: number;
|
|
25
|
+
partialBoundary: boolean;
|
|
24
26
|
scanInFlags: boolean;
|
|
25
27
|
scanInClass: boolean;
|
|
26
28
|
scanDtSpace: boolean;
|
package/dist/types/limits.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Count a string stream without charging split surrogate pairs as two replacements.
|
|
2
|
+
* @param {{ byteTail?: number }} state @param {string} text @returns {number} */
|
|
3
|
+
export declare function chunkByteLength(state: {
|
|
4
|
+
byteTail?: number;
|
|
5
|
+
}, text: string): number;
|
|
6
|
+
/** Let a cutter inspect bounded additions, including the first incomplete token.
|
|
7
|
+
* @param {string} chunk @param {number} limit @param {(part: string) => void} consume */
|
|
8
|
+
export declare function feedBounded(chunk: string, limit: number, consume: (part: string) => void): void;
|
|
1
9
|
/**
|
|
2
10
|
* The CSV limits, by option name.
|
|
3
11
|
* @type {Readonly<Record<'CSV2001' | 'CSV2002' | 'CSV2003' | 'CSV2004', string>>}
|
package/dist/types/machine.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export declare class JoslMachine {
|
|
|
9
9
|
maxDepth: number;
|
|
10
10
|
maxRetainedValues: number;
|
|
11
11
|
totalBytes: number;
|
|
12
|
+
byteTail: any;
|
|
13
|
+
scanTokenStart: number;
|
|
14
|
+
scanBareStart: number;
|
|
15
|
+
scanKeyMode: boolean;
|
|
16
|
+
scanContainers: any[];
|
|
17
|
+
scanRegExpClass: boolean;
|
|
12
18
|
retained: number;
|
|
13
19
|
depth: number;
|
|
14
20
|
onLine: any;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/josl",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.72.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -99,6 +99,6 @@
|
|
|
99
99
|
"./package.json": "./package.json"
|
|
100
100
|
},
|
|
101
101
|
"dependencies": {
|
|
102
|
-
"@jarenjs/core": "^0.
|
|
102
|
+
"@jarenjs/core": "^0.72.2"
|
|
103
103
|
}
|
|
104
104
|
}
|
package/src/csv-machine.js
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
} from '@jarenjs/core/scan';
|
|
49
49
|
|
|
50
50
|
import { CsvSyntaxError } from './errors.js';
|
|
51
|
-
import { JoslLimitError, limitOption } from './limits.js';
|
|
51
|
+
import { JoslLimitError, limitOption, chunkByteLength, feedBounded } from './limits.js';
|
|
52
52
|
import { columnOf, feedMachine, beginParseAll, offsetDateTime } from './util.js';
|
|
53
53
|
import { setObjectMember } from '@jarenjs/core/object';
|
|
54
54
|
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
@@ -253,6 +253,8 @@ export class CsvMachine {
|
|
|
253
253
|
this.limited = this.maxTotalBytes !== Infinity || this.maxRecordBytes !== Infinity
|
|
254
254
|
|| this.maxFieldBytes !== Infinity || this.maxColumns !== Infinity;
|
|
255
255
|
this.totalBytes = 0;
|
|
256
|
+
this.byteTail = undefined;
|
|
257
|
+
this.scanFieldStart = 0;
|
|
256
258
|
|
|
257
259
|
const headers = options.headers;
|
|
258
260
|
this.wantHeader = headers === true;
|
|
@@ -299,44 +301,31 @@ export class CsvMachine {
|
|
|
299
301
|
* @returns {this} The machine, for chaining
|
|
300
302
|
*/
|
|
301
303
|
feed(chunk) {
|
|
302
|
-
// Growth is append-only, so a found cursor position stays valid; but
|
|
303
|
-
// a cursor that had run off the end must look again in the new data.
|
|
304
|
-
if (this.nextDelim === -1)
|
|
305
|
-
this.nextDelim = -2;
|
|
306
|
-
if (this.nextLf === -1)
|
|
307
|
-
this.nextLf = -2;
|
|
308
|
-
if (this.nextCr === -1)
|
|
309
|
-
this.nextCr = -2;
|
|
310
304
|
if (this.limited)
|
|
311
305
|
this.count(chunk);
|
|
312
|
-
|
|
306
|
+
feedBounded(chunk, Math.min(this.maxRecordBytes, this.maxFieldBytes), (part) => {
|
|
307
|
+
// Every bounded addition can extend a delimiter search that ran
|
|
308
|
+
// off the previous buffer, just as a caller's next chunk can.
|
|
309
|
+
if (this.nextDelim === -1) this.nextDelim = -2;
|
|
310
|
+
if (this.nextLf === -1) this.nextLf = -2;
|
|
311
|
+
if (this.nextCr === -1) this.nextCr = -2;
|
|
312
|
+
feedMachine(this, part);
|
|
313
|
+
if (this.maxRecordBytes !== Infinity && utf8ByteLength(this.buf) > this.maxRecordBytes)
|
|
314
|
+
throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
|
|
315
|
+
const end = this.scanState !== S_QUOTED && this.buf.endsWith('\r') ? this.buf.length - 1 : this.buf.length;
|
|
316
|
+
this.checkFieldBytes(this.buf, this.scanFieldStart, end);
|
|
317
|
+
});
|
|
318
|
+
return this;
|
|
313
319
|
}
|
|
314
320
|
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// still being cut — the unconsumed tail plus this chunk — that would
|
|
318
|
-
// pass maxRecordBytes is refused before the concatenation that would
|
|
319
|
-
// hold it. `parseAll` counts its one text the same way.
|
|
321
|
+
// Whole-input accounting precedes buffering. Pending field and record
|
|
322
|
+
// spans are checked between bounded additions by feed().
|
|
320
323
|
count(text) {
|
|
321
324
|
if (this.maxTotalBytes !== Infinity) {
|
|
322
|
-
this.totalBytes +=
|
|
325
|
+
this.totalBytes += chunkByteLength(this, text);
|
|
323
326
|
if (this.totalBytes > this.maxTotalBytes)
|
|
324
327
|
throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.line);
|
|
325
328
|
}
|
|
326
|
-
if (this.maxRecordBytes !== Infinity) {
|
|
327
|
-
// the pending record is what the cutter has not handed off yet;
|
|
328
|
-
// a fresh chunk extends it
|
|
329
|
-
const pending = utf8ByteLength(this.buf) + utf8ByteLength(text);
|
|
330
|
-
if (pending > this.maxRecordBytes && !this.endsRecordWithin(text))
|
|
331
|
-
throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Whether a chunk can end the pending record within the bound: cheap
|
|
336
|
-
// and permissive — a terminator anywhere in the chunk means the cutter
|
|
337
|
-
// gets its chance; the span check in readSpan is the exact judge.
|
|
338
|
-
endsRecordWithin(text) {
|
|
339
|
-
return text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0;
|
|
340
329
|
}
|
|
341
330
|
|
|
342
331
|
/**
|
|
@@ -367,9 +356,9 @@ export class CsvMachine {
|
|
|
367
356
|
* @returns {Array} The completed rows
|
|
368
357
|
*/
|
|
369
358
|
parseAll(text) {
|
|
370
|
-
text = beginParseAll(this, text);
|
|
371
359
|
if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
|
|
372
360
|
throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
|
|
361
|
+
text = beginParseAll(this, text);
|
|
373
362
|
this.readSpan(text, 0, text.length);
|
|
374
363
|
return this.outRows;
|
|
375
364
|
}
|
|
@@ -439,7 +428,7 @@ export class CsvMachine {
|
|
|
439
428
|
// per-character machine. A record that does contain a quote is
|
|
440
429
|
// handed to the machine below, one record at a time; one that
|
|
441
430
|
// visibly STARTS with a quote skips the lookups outright.
|
|
442
|
-
if (state === S_PLAIN || (state === S_START && buf.charCodeAt(pos) !== quote)) {
|
|
431
|
+
if (this.maxFieldBytes === Infinity && (state === S_PLAIN || (state === S_START && buf.charCodeAt(pos) !== quote))) {
|
|
443
432
|
if (qi !== -1 && qi < pos)
|
|
444
433
|
qi = quote < 0 ? -1 : buf.indexOf(this.quoteChar, pos);
|
|
445
434
|
const qlimit = qi < 0 ? len : qi;
|
|
@@ -509,6 +498,8 @@ export class CsvMachine {
|
|
|
509
498
|
continue;
|
|
510
499
|
case S_PLAIN: {
|
|
511
500
|
if (c === delim) {
|
|
501
|
+
this.checkFieldBytes(buf, this.scanFieldStart, pos, start);
|
|
502
|
+
this.scanFieldStart = pos + 1;
|
|
512
503
|
state = S_START;
|
|
513
504
|
pos++;
|
|
514
505
|
continue;
|
|
@@ -517,6 +508,7 @@ export class CsvMachine {
|
|
|
517
508
|
pos++;
|
|
518
509
|
this.readSpan(buf, start, pos);
|
|
519
510
|
start = pos;
|
|
511
|
+
this.scanFieldStart = pos;
|
|
520
512
|
state = S_START;
|
|
521
513
|
continue outer;
|
|
522
514
|
}
|
|
@@ -539,6 +531,7 @@ export class CsvMachine {
|
|
|
539
531
|
pos++;
|
|
540
532
|
this.readSpan(buf, start, pos);
|
|
541
533
|
start = pos;
|
|
534
|
+
this.scanFieldStart = pos;
|
|
542
535
|
state = S_START;
|
|
543
536
|
continue outer;
|
|
544
537
|
}
|
|
@@ -597,6 +590,7 @@ export class CsvMachine {
|
|
|
597
590
|
return;
|
|
598
591
|
this.buf = this.buf.slice(start);
|
|
599
592
|
this.scanPos -= start;
|
|
593
|
+
this.scanFieldStart = Math.max(0, this.scanFieldStart - start);
|
|
600
594
|
// The scanner cursors are absolute in the buffer, so they shift with
|
|
601
595
|
// it; one already consumed has no meaning in the new buffer.
|
|
602
596
|
if (this.nextDelim >= 0)
|
|
@@ -611,6 +605,13 @@ export class CsvMachine {
|
|
|
611
605
|
|
|
612
606
|
//#region record parsing
|
|
613
607
|
|
|
608
|
+
/** Check a field span while its record is still incomplete. */
|
|
609
|
+
checkFieldBytes(text, start, end, recordStart = 0) {
|
|
610
|
+
if (this.comment >= 0 && text.charCodeAt(recordStart) === this.comment) return;
|
|
611
|
+
if (this.maxFieldBytes !== Infinity && utf8ByteLength(text, start, end) > this.maxFieldBytes)
|
|
612
|
+
throw new JoslLimitError('CSV2003', 'a field exceeds maxFieldBytes', this.maxFieldBytes, this.line);
|
|
613
|
+
}
|
|
614
|
+
|
|
614
615
|
// Read every record in `text[pos, end)`. A cut span always carries its
|
|
615
616
|
// own terminator, so this is the same loop the whole-document form runs
|
|
616
617
|
// and a blank line is a record of one empty field in both.
|
package/src/csv.js
CHANGED
|
@@ -166,7 +166,8 @@ export function sniffCsvDialect(text, options = undefined) {
|
|
|
166
166
|
let bestWidth = 0;
|
|
167
167
|
let bestRows = null;
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
const candidates = opts.delimiter && opts.delimiter !== 'auto' ? [opts.delimiter] : CANDIDATES;
|
|
170
|
+
for (const candidate of candidates) {
|
|
170
171
|
const probe = probeDelimiter(sample, candidate, opts);
|
|
171
172
|
if (probe.score > bestScore || (probe.score === bestScore && probe.width > bestWidth)) {
|
|
172
173
|
bestScore = probe.score;
|
package/src/jsonx-stream.js
CHANGED
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
} from '@jarenjs/core/scan';
|
|
80
80
|
|
|
81
81
|
import { JsonxSyntaxError } from './errors.js';
|
|
82
|
-
import { JoslLimitError, limitOption } from './limits.js';
|
|
82
|
+
import { JoslLimitError, limitOption, chunkByteLength, feedBounded } from './limits.js';
|
|
83
83
|
import { setObjectMember } from '@jarenjs/core/object';
|
|
84
84
|
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
85
85
|
import {
|
|
@@ -162,6 +162,7 @@ export class JsonxMachine {
|
|
|
162
162
|
this.maxDepth = limitOption(options, 'maxDepth');
|
|
163
163
|
this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
|
|
164
164
|
this.totalBytes = 0;
|
|
165
|
+
this.byteTail = undefined;
|
|
165
166
|
this.retained = 0; // values linked into the tree
|
|
166
167
|
this.partialFrom = -1; // body offset the next text-partial delta starts at
|
|
167
168
|
this.partialHold = ''; // lone high surrogate held back for the next delta
|
|
@@ -179,6 +180,7 @@ export class JsonxMachine {
|
|
|
179
180
|
this.lineStart = 0; // buf offset of the current line start (may go negative after compaction)
|
|
180
181
|
// resumable token-scan state
|
|
181
182
|
this.scanPos = -1; // where the pending token's scan left off
|
|
183
|
+
this.partialBoundary = true;
|
|
182
184
|
this.scanInFlags = false; // regexp scan: past the closing '/'
|
|
183
185
|
this.scanInClass = false; // regexp scan: inside [...]
|
|
184
186
|
this.scanDtSpace = false; // scalar scan: crossed a date-time space separator
|
|
@@ -198,19 +200,21 @@ export class JsonxMachine {
|
|
|
198
200
|
throw new Error('cannot feed after end()');
|
|
199
201
|
if (chunk.length !== 0) {
|
|
200
202
|
if (this.maxTotalBytes !== Infinity) {
|
|
201
|
-
this.totalBytes +=
|
|
203
|
+
this.totalBytes += chunkByteLength(this, chunk);
|
|
202
204
|
if (this.totalBytes > this.maxTotalBytes)
|
|
203
205
|
throw new JoslLimitError('JSONX2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.curLine);
|
|
204
206
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
let remaining = chunk.length;
|
|
208
|
+
try {
|
|
209
|
+
feedBounded(chunk, this.maxTokenBytes, (part) => {
|
|
210
|
+
remaining -= part.length;
|
|
211
|
+
this.partialBoundary = remaining === 0;
|
|
212
|
+
this.buf += part;
|
|
213
|
+
this.pump();
|
|
214
|
+
if (this.scanPos >= 0) this.token(this.buf, this.pos, this.buf.length);
|
|
215
|
+
});
|
|
211
216
|
}
|
|
212
|
-
this.
|
|
213
|
-
this.pump();
|
|
217
|
+
finally { this.partialBoundary = true; }
|
|
214
218
|
}
|
|
215
219
|
return this;
|
|
216
220
|
}
|
|
@@ -426,7 +430,8 @@ export class JsonxMachine {
|
|
|
426
430
|
if (c === CC_DQUOTE) {
|
|
427
431
|
const end = this.scanString(buf, pos);
|
|
428
432
|
if (end < 0) {
|
|
429
|
-
|
|
433
|
+
this.token(buf, pos, buf.length);
|
|
434
|
+
if (this.partialText && this.partialBoundary)
|
|
430
435
|
this.emitPartialText(buf, pos, buf.length, true);
|
|
431
436
|
return -1;
|
|
432
437
|
}
|
package/src/limits.js
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
3
|
+
|
|
4
|
+
/** Count a string stream without charging split surrogate pairs as two replacements.
|
|
5
|
+
* @param {{ byteTail?: number }} state @param {string} text @returns {number} */
|
|
6
|
+
export function chunkByteLength(state, text) {
|
|
7
|
+
if (text.length === 0) return 0;
|
|
8
|
+
const first = text.charCodeAt(0);
|
|
9
|
+
const joined = state.byteTail >= 0xD800 && state.byteTail <= 0xDBFF
|
|
10
|
+
&& first >= 0xDC00 && first <= 0xDFFF;
|
|
11
|
+
state.byteTail = text.charCodeAt(text.length - 1);
|
|
12
|
+
return utf8ByteLength(text) - (joined ? 2 : 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Let a cutter inspect bounded additions, including the first incomplete token.
|
|
16
|
+
* @param {string} chunk @param {number} limit @param {(part: string) => void} consume */
|
|
17
|
+
export function feedBounded(chunk, limit, consume) {
|
|
18
|
+
if (limit === Infinity || chunk.length === 0) { consume(chunk); return; }
|
|
19
|
+
const size = Math.max(1, Math.min(16384, limit));
|
|
20
|
+
for (let offset = 0; offset < chunk.length; offset += size) consume(chunk.slice(offset, offset + size));
|
|
21
|
+
}
|
|
22
|
+
|
|
1
23
|
//#region hostile-input limits
|
|
2
24
|
// The three readers and the CSV machine take the same shape of guard:
|
|
3
25
|
// a limit that defaults to Infinity — nothing in this package refuses a
|
|
4
26
|
// document by size unless a caller asks — and, once asked, is checked
|
|
5
27
|
// while the offending text is still in cutter, token or container
|
|
6
|
-
// state
|
|
28
|
+
// state. Limited streams inspect bounded additions before accepting another;
|
|
29
|
+
// complete spans are checked before decoding or linking values. Every limit
|
|
7
30
|
// counts UTF-8 bytes, never JavaScript code units: a limit stated in
|
|
8
31
|
// bytes is the one an HTTP body limit, a disk quota or a proxy speaks.
|
|
9
32
|
//
|
package/src/machine.js
CHANGED
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
} from '@jarenjs/core/scan';
|
|
54
54
|
|
|
55
55
|
import { JoslSyntaxError } from './errors.js';
|
|
56
|
-
import { JoslLimitError, limitOption } from './limits.js';
|
|
56
|
+
import { JoslLimitError, limitOption, chunkByteLength, feedBounded } from './limits.js';
|
|
57
57
|
import {
|
|
58
58
|
LocalDate,
|
|
59
59
|
LocalTime,
|
|
@@ -81,6 +81,8 @@ const S_LITERAL = 2; // '...' (single line)
|
|
|
81
81
|
const S_ML_BASIC = 3; // """..."""
|
|
82
82
|
const S_ML_LITERAL = 4; // '''...'''
|
|
83
83
|
const S_COMMENT = 5;
|
|
84
|
+
const S_REGEXP = 6;
|
|
85
|
+
const S_REGEXP_FLAGS = 7;
|
|
84
86
|
|
|
85
87
|
// Runs of characters that cannot end a logical line or change the cutter's
|
|
86
88
|
// state. The cutter skips them with the regex engine rather than stepping
|
|
@@ -89,7 +91,7 @@ const S_COMMENT = 5;
|
|
|
89
91
|
// logical line spans without a separate walk over it.
|
|
90
92
|
// The basic/literal classes serve both the single- and multi-line states:
|
|
91
93
|
// a `'` is inert inside a basic string and a `"` inside a literal one.
|
|
92
|
-
const RUN_NONE = /[^\n"'#[\]]*/y;
|
|
94
|
+
const RUN_NONE = /[^\n"'#[\]/]*/y;
|
|
93
95
|
const RUN_BASIC = /[^\n"\\]*/y;
|
|
94
96
|
const RUN_LITERAL = /[^\n']*/y;
|
|
95
97
|
|
|
@@ -146,6 +148,12 @@ export class JoslMachine {
|
|
|
146
148
|
this.maxDepth = limitOption(options, 'maxDepth');
|
|
147
149
|
this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
|
|
148
150
|
this.totalBytes = 0;
|
|
151
|
+
this.byteTail = undefined;
|
|
152
|
+
this.scanTokenStart = -1;
|
|
153
|
+
this.scanBareStart = -1;
|
|
154
|
+
this.scanKeyMode = true;
|
|
155
|
+
this.scanContainers = [];
|
|
156
|
+
this.scanRegExpClass = false;
|
|
149
157
|
this.retained = 0; // values linked into the root since the last detachment
|
|
150
158
|
this.depth = 0; // inline container nesting while a value is parsed
|
|
151
159
|
// Logical-line sink used by the CST layer: reports each line's source
|
|
@@ -185,18 +193,20 @@ export class JoslMachine {
|
|
|
185
193
|
*/
|
|
186
194
|
feed(chunk) {
|
|
187
195
|
if (this.maxTotalBytes !== Infinity) {
|
|
188
|
-
this.totalBytes +=
|
|
196
|
+
this.totalBytes += chunkByteLength(this, chunk);
|
|
189
197
|
if (this.totalBytes > this.maxTotalBytes)
|
|
190
198
|
throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.startLine);
|
|
191
199
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
feedBounded(chunk, Math.min(this.maxRecordBytes, this.maxTokenBytes), (part) => {
|
|
201
|
+
feedMachine(this, part);
|
|
202
|
+
const end = this.scanDepth === 0 && (this.scanState === S_NONE || this.scanState === S_COMMENT)
|
|
203
|
+
&& this.buf.endsWith('\r') ? this.buf.length - 1 : this.buf.length;
|
|
204
|
+
if (this.maxRecordBytes !== Infinity && utf8ByteLength(this.buf, 0, end) > this.maxRecordBytes)
|
|
205
|
+
throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
|
|
206
|
+
if (this.scanTokenStart >= 0) this.token(this.buf, this.scanTokenStart, this.buf.length);
|
|
207
|
+
if (this.scanBareStart >= 0) this.token(this.buf, this.scanBareStart, this.buf.length);
|
|
208
|
+
});
|
|
209
|
+
return this;
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
/**
|
|
@@ -232,9 +242,9 @@ export class JoslMachine {
|
|
|
232
242
|
* @returns {*} The completed root value
|
|
233
243
|
*/
|
|
234
244
|
parseAll(text) {
|
|
235
|
-
text = beginParseAll(this, text);
|
|
236
245
|
if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
|
|
237
246
|
throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
|
|
247
|
+
text = beginParseAll(this, text);
|
|
238
248
|
// positions are offsets into the whole source, which starts at line 1
|
|
239
249
|
this.lineOrigin = 1;
|
|
240
250
|
const tracking = this.onEvent !== null;
|
|
@@ -327,20 +337,54 @@ export class JoslMachine {
|
|
|
327
337
|
let state = this.scanState;
|
|
328
338
|
let depth = this.scanDepth;
|
|
329
339
|
let nl = this.scanNl;
|
|
340
|
+
let tokenStart = this.scanTokenStart;
|
|
341
|
+
let bareStart = this.scanBareStart;
|
|
342
|
+
let keyMode = this.scanKeyMode;
|
|
343
|
+
const containers = this.scanContainers;
|
|
344
|
+
const limited = this.maxTokenBytes !== Infinity;
|
|
330
345
|
const ended = this.ended;
|
|
331
346
|
outer:
|
|
332
347
|
while (pos < buf.length) {
|
|
333
348
|
switch (state) {
|
|
334
349
|
case S_NONE: {
|
|
335
|
-
pos = skipRun(RUN_NONE, buf, pos);
|
|
350
|
+
if (!limited) pos = skipRun(RUN_NONE, buf, pos);
|
|
336
351
|
if (pos >= buf.length)
|
|
337
352
|
break outer;
|
|
338
353
|
const c = buf.charCodeAt(pos);
|
|
354
|
+
if (limited) {
|
|
355
|
+
const boundary = c === CC_SPACE || c === CC_TAB || c === CC_CR || c === CC_LF
|
|
356
|
+
|| c === CC_EQ || c === CC_COMMA || c === CC_LBRACE || c === CC_RBRACE
|
|
357
|
+
|| c === CC_LBRACKET || c === CC_RBRACKET || c === CC_HASH
|
|
358
|
+
|| c === CC_DQUOTE || c === CC_SQUOTE || (keyMode && c === CC_DOT)
|
|
359
|
+
|| (!keyMode && c === CC_SLASH);
|
|
360
|
+
if (boundary) {
|
|
361
|
+
if (bareStart >= 0) this.token(buf, bareStart, pos);
|
|
362
|
+
bareStart = -1;
|
|
363
|
+
}
|
|
364
|
+
else if (bareStart < 0) bareStart = pos;
|
|
365
|
+
if (c === CC_EQ) keyMode = false;
|
|
366
|
+
else if (c === CC_LBRACE) { containers.push('table'); keyMode = true; }
|
|
367
|
+
else if (c === CC_LBRACKET) {
|
|
368
|
+
const header = keyMode && (containers.length === 0 || containers.at(-1) === 'header');
|
|
369
|
+
containers.push(header ? 'header' : 'array');
|
|
370
|
+
keyMode = header;
|
|
371
|
+
}
|
|
372
|
+
else if (c === CC_RBRACKET || c === CC_RBRACE) { containers.pop(); keyMode = false; }
|
|
373
|
+
else if (c === CC_COMMA) keyMode = containers.at(-1) === 'table';
|
|
374
|
+
}
|
|
375
|
+
if (c === CC_SLASH) {
|
|
376
|
+
tokenStart = pos++;
|
|
377
|
+
this.scanRegExpClass = false;
|
|
378
|
+
state = S_REGEXP;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
339
381
|
if (c === CC_LF) {
|
|
340
382
|
if (depth === 0) {
|
|
341
383
|
this.cutLine(buf, lineStart, pos, nl);
|
|
342
384
|
lineStart = pos + 1;
|
|
343
385
|
nl = 0;
|
|
386
|
+
keyMode = true;
|
|
387
|
+
containers.length = 0;
|
|
344
388
|
}
|
|
345
389
|
else
|
|
346
390
|
nl++;
|
|
@@ -348,6 +392,7 @@ export class JoslMachine {
|
|
|
348
392
|
break;
|
|
349
393
|
}
|
|
350
394
|
if (c === CC_DQUOTE || c === CC_SQUOTE) {
|
|
395
|
+
tokenStart = pos;
|
|
351
396
|
if (pos + 2 >= buf.length && !ended)
|
|
352
397
|
break outer; // may be a triple delimiter split across chunks
|
|
353
398
|
if (buf.charCodeAt(pos + 1) === c && buf.charCodeAt(pos + 2) === c) {
|
|
@@ -383,6 +428,8 @@ export class JoslMachine {
|
|
|
383
428
|
this.cutLine(buf, lineStart, at, nl);
|
|
384
429
|
lineStart = at + 1;
|
|
385
430
|
nl = 0;
|
|
431
|
+
keyMode = true;
|
|
432
|
+
containers.length = 0;
|
|
386
433
|
}
|
|
387
434
|
else
|
|
388
435
|
nl++;
|
|
@@ -390,6 +437,28 @@ export class JoslMachine {
|
|
|
390
437
|
pos = at + 1;
|
|
391
438
|
break;
|
|
392
439
|
}
|
|
440
|
+
case S_REGEXP: {
|
|
441
|
+
const c = buf.charCodeAt(pos);
|
|
442
|
+
if (c === CC_BACKSLASH) {
|
|
443
|
+
if (pos + 1 >= buf.length && !ended) break outer;
|
|
444
|
+
pos += 2;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
if (c === CC_LF) this.cutLine(buf, lineStart, pos, nl);
|
|
448
|
+
if (c === CC_LBRACKET) this.scanRegExpClass = true;
|
|
449
|
+
else if (c === CC_RBRACKET) this.scanRegExpClass = false;
|
|
450
|
+
else if (c === CC_SLASH && !this.scanRegExpClass) state = S_REGEXP_FLAGS;
|
|
451
|
+
pos++;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case S_REGEXP_FLAGS:
|
|
455
|
+
if (isAsciiLetterCode(buf.charCodeAt(pos))) pos++;
|
|
456
|
+
else {
|
|
457
|
+
this.token(buf, tokenStart, pos);
|
|
458
|
+
tokenStart = -1;
|
|
459
|
+
state = S_NONE;
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
393
462
|
case S_BASIC:
|
|
394
463
|
case S_LITERAL: {
|
|
395
464
|
const basic = state === S_BASIC;
|
|
@@ -418,6 +487,8 @@ export class JoslMachine {
|
|
|
418
487
|
}
|
|
419
488
|
state = S_NONE; // the run only stops on the closing quote
|
|
420
489
|
pos++;
|
|
490
|
+
this.token(buf, tokenStart, pos);
|
|
491
|
+
tokenStart = -1;
|
|
421
492
|
break;
|
|
422
493
|
}
|
|
423
494
|
case S_ML_BASIC:
|
|
@@ -444,8 +515,11 @@ export class JoslMachine {
|
|
|
444
515
|
run++;
|
|
445
516
|
if (run === buf.length && run - pos < 3 && !ended)
|
|
446
517
|
break outer; // quote run may continue in the next chunk
|
|
447
|
-
if (run - pos >= 3)
|
|
518
|
+
if (run - pos >= 3) {
|
|
448
519
|
state = S_NONE;
|
|
520
|
+
this.token(buf, tokenStart, run);
|
|
521
|
+
tokenStart = -1;
|
|
522
|
+
}
|
|
449
523
|
pos = run;
|
|
450
524
|
break;
|
|
451
525
|
}
|
|
@@ -460,6 +534,9 @@ export class JoslMachine {
|
|
|
460
534
|
this.scanState = state;
|
|
461
535
|
this.scanDepth = depth;
|
|
462
536
|
this.scanNl = nl;
|
|
537
|
+
this.scanTokenStart = tokenStart < 0 ? -1 : tokenStart - lineStart;
|
|
538
|
+
this.scanBareStart = bareStart < 0 ? -1 : bareStart - lineStart;
|
|
539
|
+
this.scanKeyMode = keyMode;
|
|
463
540
|
}
|
|
464
541
|
|
|
465
542
|
// `innerNl` is how many newlines the cutter already counted inside this
|
|
@@ -876,6 +953,7 @@ export class JoslMachine {
|
|
|
876
953
|
const start = pos;
|
|
877
954
|
while (pos < line.length && isAsciiLetterCode(line.charCodeAt(pos)))
|
|
878
955
|
pos++;
|
|
956
|
+
this.token(line, start, pos);
|
|
879
957
|
const word = line.slice(start, pos);
|
|
880
958
|
switch (word) {
|
|
881
959
|
case 'true': return [true, this.checkValueEnd(line, pos)];
|
|
@@ -899,6 +977,7 @@ export class JoslMachine {
|
|
|
899
977
|
const start = p;
|
|
900
978
|
while (p < line.length && isAsciiLetterCode(line.charCodeAt(p)))
|
|
901
979
|
p++;
|
|
980
|
+
this.token(line, pos, p);
|
|
902
981
|
const word = line.slice(start, p);
|
|
903
982
|
if (word === 'inf')
|
|
904
983
|
return [neg ? -Infinity : Infinity, this.checkValueEnd(line, p)];
|
|
@@ -1216,11 +1295,13 @@ export class JoslMachine {
|
|
|
1216
1295
|
break;
|
|
1217
1296
|
pos++;
|
|
1218
1297
|
}
|
|
1298
|
+
this.token(line, start, pos + 1);
|
|
1219
1299
|
const body = line.slice(start + 1, pos);
|
|
1220
1300
|
pos++; // consume '/'
|
|
1221
1301
|
const flagStart = pos;
|
|
1222
1302
|
while (pos < line.length && isAsciiLetterCode(line.charCodeAt(pos)))
|
|
1223
1303
|
pos++;
|
|
1304
|
+
this.token(line, start, pos);
|
|
1224
1305
|
const flags = line.slice(flagStart, pos);
|
|
1225
1306
|
try {
|
|
1226
1307
|
return [new RegExp(body, flags), this.checkValueEnd(line, pos)];
|
|
@@ -1237,6 +1318,7 @@ export class JoslMachine {
|
|
|
1237
1318
|
return this.parseNumber(line, pos);
|
|
1238
1319
|
let m = stickyExec(RE_DATETIME, line, pos);
|
|
1239
1320
|
if (m !== null) {
|
|
1321
|
+
this.token(line, pos, pos + m[0].length);
|
|
1240
1322
|
const year = Number(m[1]);
|
|
1241
1323
|
const month = Number(m[2]);
|
|
1242
1324
|
const day = Number(m[3]);
|
|
@@ -1261,6 +1343,7 @@ export class JoslMachine {
|
|
|
1261
1343
|
}
|
|
1262
1344
|
m = stickyExec(RE_TIMEONLY, line, pos);
|
|
1263
1345
|
if (m !== null) {
|
|
1346
|
+
this.token(line, pos, pos + m[0].length);
|
|
1264
1347
|
const hour = Number(m[1]);
|
|
1265
1348
|
const minute = Number(m[2]);
|
|
1266
1349
|
const second = Number(m[3]);
|
|
@@ -1309,6 +1392,7 @@ export class JoslMachine {
|
|
|
1309
1392
|
while (p < line.length && isDigitCode(line.charCodeAt(p)))
|
|
1310
1393
|
p++;
|
|
1311
1394
|
if ((p - pos === 1 || c0 !== CC_0) && atValueEnd(line, p)) {
|
|
1395
|
+
this.token(line, pos, p);
|
|
1312
1396
|
const source = line.slice(pos, p);
|
|
1313
1397
|
return [this.intValue(pos, source, false, source), p];
|
|
1314
1398
|
}
|
|
@@ -1325,6 +1409,7 @@ export class JoslMachine {
|
|
|
1325
1409
|
m = stickyExec(RE_BIN, line, pos);
|
|
1326
1410
|
}
|
|
1327
1411
|
if (m !== null) {
|
|
1412
|
+
this.token(line, pos, pos + m[0].length);
|
|
1328
1413
|
const big = this.bigIntCheck(pos, m[1]);
|
|
1329
1414
|
const stripped = (big ? m[0].slice(0, -1) : m[0]).replace(/_/g, '');
|
|
1330
1415
|
const end = this.checkValueEnd(line, pos + m[0].length);
|
|
@@ -1333,6 +1418,7 @@ export class JoslMachine {
|
|
|
1333
1418
|
m = stickyExec(RE_NUM, line, pos);
|
|
1334
1419
|
if (m === null)
|
|
1335
1420
|
this.err(pos, 'invalid number');
|
|
1421
|
+
this.token(line, pos, pos + m[0].length);
|
|
1336
1422
|
const big = this.bigIntCheck(pos, m[1]);
|
|
1337
1423
|
const token = big ? m[0].slice(0, -1) : m[0];
|
|
1338
1424
|
const isFloat = /[.eE]/.test(token);
|