|
1 | 1 | const MAX_MEMORY_JSON_NODES = 100_000 |
2 | 2 | const MAX_MEMORY_JSON_DEPTH = 64 |
3 | 3 |
|
4 | | -/** Rejects oversized or unsafe plain JSON before allocating its serialized representation. */ |
| 4 | +/** Counts JSON escapes without allocating the escaped string. */ |
| 5 | +function quotedStringBytes(value: string, remaining: number): number | undefined { |
| 6 | + let bytes = 2 |
| 7 | + for (let index = 0; index < value.length && bytes <= remaining; index++) { |
| 8 | + const code = value.charCodeAt(index) |
| 9 | + if (code === 0x22 || code === 0x5c) bytes += 2 |
| 10 | + else if (code < 0x20) bytes += (code >= 8 && code <= 10) || code === 12 || code === 13 ? 2 : 6 |
| 11 | + else if (code < 0x80) bytes++ |
| 12 | + else if (code < 0x800) bytes += 2 |
| 13 | + else if (code >= 0xd800 && code <= 0xdbff) { |
| 14 | + const next = value.charCodeAt(index + 1) |
| 15 | + if (next >= 0xdc00 && next <= 0xdfff) { |
| 16 | + bytes += 4 |
| 17 | + index++ |
| 18 | + } else bytes += 6 |
| 19 | + } else bytes += code >= 0xdc00 && code <= 0xdfff ? 6 : 3 |
| 20 | + } |
| 21 | + return bytes <= remaining ? bytes : undefined |
| 22 | +} |
| 23 | + |
| 24 | +/** Captures bounded plain JSON once, without executing accessors or serializing the source graph. */ |
5 | 25 | export function stringifyBoundedMemoryJson(value: unknown, maxBytes: number): string | undefined { |
6 | 26 | let nodes = 0 |
7 | | - let minimumBytes = 0 |
| 27 | + let bytes = 0 |
| 28 | + const invalid = Symbol('invalid JSON') |
8 | 29 | const ancestors = new WeakSet<object>() |
9 | | - const visit = (item: unknown, depth: number): boolean => { |
10 | | - if (++nodes > MAX_MEMORY_JSON_NODES || depth > MAX_MEMORY_JSON_DEPTH) return false |
11 | | - if (typeof item === 'string') minimumBytes += Buffer.byteLength(item, 'utf8') + 2 |
12 | | - else if (item === null || item === undefined) minimumBytes += 4 |
13 | | - else if (typeof item === 'number') |
14 | | - minimumBytes += Number.isFinite(item) ? String(item).length : 4 |
15 | | - else if (typeof item === 'boolean') minimumBytes += item ? 4 : 5 |
16 | | - else if (typeof item !== 'object') return false |
17 | | - else { |
18 | | - if (ancestors.has(item) || 'toJSON' in item) return false |
19 | | - const prototype = Object.getPrototypeOf(item) |
20 | | - if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null) return false |
21 | | - ancestors.add(item) |
22 | | - minimumBytes += 2 |
23 | | - if (Array.isArray(item)) { |
24 | | - if (item.length > MAX_MEMORY_JSON_NODES - nodes) return false |
25 | | - for (let index = 0; index < item.length; index++) { |
26 | | - const field = Object.getOwnPropertyDescriptor(item, index) |
27 | | - if (field && !('value' in field)) return false |
28 | | - if (index > 0) minimumBytes++ |
29 | | - if (!visit(field?.value, depth + 1)) return false |
30 | | - } |
31 | | - } else { |
32 | | - let fields = 0 |
33 | | - for (const key in item) { |
34 | | - if (!Object.hasOwn(item, key)) continue |
35 | | - const field = Object.getOwnPropertyDescriptor(item, key) |
36 | | - if (!field || !('value' in field)) return false |
37 | | - if (fields++ > 0) minimumBytes++ |
38 | | - minimumBytes += Buffer.byteLength(key, 'utf8') + 3 |
39 | | - if (!visit(field.value, depth + 1)) return false |
| 30 | + const addBytes = (count: number): boolean => { |
| 31 | + bytes += count |
| 32 | + return bytes <= maxBytes |
| 33 | + } |
| 34 | + const capture = (item: unknown, depth: number): unknown => { |
| 35 | + if (++nodes > MAX_MEMORY_JSON_NODES || depth > MAX_MEMORY_JSON_DEPTH) return invalid |
| 36 | + if (typeof item === 'string') { |
| 37 | + const count = quotedStringBytes(item, maxBytes - bytes) |
| 38 | + if (count === undefined || !addBytes(count)) return invalid |
| 39 | + return item |
| 40 | + } |
| 41 | + if (item === null || item === undefined) return addBytes(4) ? item : invalid |
| 42 | + if (typeof item === 'number') |
| 43 | + return addBytes(Number.isFinite(item) ? String(item).length : 4) ? item : invalid |
| 44 | + if (typeof item === 'boolean') return addBytes(item ? 4 : 5) ? item : invalid |
| 45 | + if (typeof item !== 'object' || ancestors.has(item) || 'toJSON' in item) return invalid |
| 46 | + const prototype = Object.getPrototypeOf(item) |
| 47 | + const isArray = Array.isArray(item) |
| 48 | + if (!isArray && prototype !== Object.prototype && prototype !== null) return invalid |
| 49 | + if (!addBytes(2)) return invalid |
| 50 | + ancestors.add(item) |
| 51 | + const snapshot: Record<string, unknown> | unknown[] = isArray |
| 52 | + ? Object.setPrototypeOf([], null) |
| 53 | + : Object.create(null) |
| 54 | + if (isArray) { |
| 55 | + const length = Object.getOwnPropertyDescriptor(item, 'length')?.value |
| 56 | + if (typeof length !== 'number' || length > MAX_MEMORY_JSON_NODES - nodes) return invalid |
| 57 | + for (let index = 0; index < length; index++) { |
| 58 | + const field = Object.getOwnPropertyDescriptor(item, index) |
| 59 | + if (field && !('value' in field)) return invalid |
| 60 | + if (index > 0 && !addBytes(1)) return invalid |
| 61 | + const captured = capture(field?.value ?? null, depth + 1) |
| 62 | + if (captured === invalid) return invalid |
| 63 | + Object.defineProperty(snapshot, index, { value: captured, enumerable: true }) |
| 64 | + } |
| 65 | + } else { |
| 66 | + let fields = 0 |
| 67 | + for (const key in item) { |
| 68 | + const field = Object.getOwnPropertyDescriptor(item, key) |
| 69 | + if (!field || !field.enumerable) continue |
| 70 | + if (!('value' in field)) return invalid |
| 71 | + if (field.value === undefined) { |
| 72 | + if (++nodes > MAX_MEMORY_JSON_NODES) return invalid |
| 73 | + continue |
40 | 74 | } |
| 75 | + const keyBytes = quotedStringBytes(key, maxBytes - bytes) |
| 76 | + if (keyBytes === undefined || !addBytes(keyBytes + 1 + (fields++ > 0 ? 1 : 0))) |
| 77 | + return invalid |
| 78 | + const captured = capture(field.value, depth + 1) |
| 79 | + if (captured === invalid) return invalid |
| 80 | + Object.defineProperty(snapshot, key, { value: captured, enumerable: true }) |
41 | 81 | } |
42 | | - ancestors.delete(item) |
43 | 82 | } |
44 | | - return minimumBytes <= maxBytes |
| 83 | + ancestors.delete(item) |
| 84 | + return snapshot |
45 | 85 | } |
46 | 86 | try { |
47 | | - if (!visit(value, 0)) return undefined |
48 | | - const json = JSON.stringify(value) |
49 | | - return json !== undefined && Buffer.byteLength(json, 'utf8') <= maxBytes ? json : undefined |
| 87 | + const snapshot = capture(value, 0) |
| 88 | + return snapshot === invalid ? undefined : JSON.stringify(snapshot) |
50 | 89 | } catch { |
51 | 90 | return undefined |
52 | 91 | } |
|
0 commit comments