diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f18b1c..6b02b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. - Fix an image inside another image's description: it flattens into the alt text, where it was refused. +- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`: on + Node 24, Deno and Chrome, `markdownToAdf` could read it as another key once the process had parsed + JSON holding a key with a backslash ([V8 bug](https://issues.chromium.org/issues/521080746)). - Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. diff --git a/src/json-text.test.ts b/src/json-text.test.ts new file mode 100644 index 0000000..93da487 --- /dev/null +++ b/src/json-text.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import fc from 'fast-check' +import test from 'node:test' + +import type { JsonSpelling } from './canonical-json.ts' +import { jsonValue, propertyRuns } from './conformance/property-harness.ts' +import { parseJsonText } from './json-text.ts' +import { serializeCanonicalJson } from './canonical-json.ts' + +test('reads every JSON value kind', () => { + assert.deepEqual(parseJsonText(' {"a":[1,-0.5e2,true,false,null,"x"],"b":{}} '), { value: { a: [1, -50, true, false, null, 'x'], b: {} } }) + assert.deepEqual(parseJsonText('[]'), { value: [] }) + assert.deepEqual(parseJsonText('[ ]'), { value: [] }) + assert.deepEqual(parseJsonText('{ }'), { value: {} }) + assert.deepEqual(parseJsonText('[[],{}]'), { value: [[], {}] }) + assert.ok(Object.is(parseJsonText('-0')?.value, -0)) +}) + +test('decodes every string escape', () => { + assert.deepEqual(parseJsonText('"\\"\\\\\\/\\b\\f\\n\\r\\t\\u00e9\\uD83C\\uDF89\\ud800"'), { value: '"\\/\b\f\n\r\té\u{1f389}\ud800' }) +}) + +test('keys stay the keys the text spells after V8 has read a colliding key (https://issues.chromium.org/issues/521080746)', () => { + const canary = '{"\\r":1,"\\f":2}' + JSON.parse(canary) + JSON.parse('{"\\r":3,"\\\\":4}') + assert.deepEqual(Object.keys(parseJsonText(canary)?.value ?? {}), ['\r', '\f']) +}) + +test('a __proto__ key is an own key', () => { + const read = parseJsonText('{"__proto__":{"a":1}}')?.value + assert.deepEqual(Object.keys(read ?? {}), ['__proto__']) + assert.equal(Object.getPrototypeOf(read), Object.prototype) +}) + +test('a repeated key keeps its first position and its last value, as JSON.parse does', () => { + const read = parseJsonText('{"a":1,"b":2,"a":3}')?.value + assert.deepEqual(read, { a: 3, b: 2 }) + assert.deepEqual(Object.keys(read ?? {}), ['a', 'b']) +}) + +test('a number past the double range reads as JSON.parse reads it', () => { + assert.deepEqual(parseJsonText('1e999'), { value: Infinity }) +}) + +test('refuses text JSON.parse refuses', () => { + const refused = ['', ' ', '{', '[', '[1', '[1,', '[1,]', '{"a"}', '{"a":}', '{"a":1,}', '{a:1}', '{"a" 1}', '{,}', '01', '1.', '.5', '-', '1e', '+1', '"a', '"\u0001"', '"\\x"', '"\\u12"', '"\\u12g4"', 'tru', 'nul', '1 2', '[1]]', '}', "'a'", 'NaN', ' 1'] + for (const text of refused) { + assert.throws(() => JSON.parse(text), SyntaxError, text) + assert.equal(parseJsonText(text), undefined, text) + } +}) + +test('reads a value nested far past the call stack without recursing', () => { + const depth = 100_000 + assert.ok(parseJsonText(`${'['.repeat(depth)}${']'.repeat(depth)}`) !== undefined) + assert.ok(parseJsonText(`${'{"a":'.repeat(depth)}1${'}'.repeat(depth)}`) !== undefined) +}) + +test('reads back every value the canonical serializer writes', () => { + fc.assert( + fc.property(jsonValue, fc.constantFrom('compact', 'two-space'), (value, spelling) => { + assert.deepEqual(parseJsonText(serializeCanonicalJson(value, spelling)), { value }) + }), + propertyRuns(500), + ) +}) + +test('accepts exactly the text JSON.parse accepts', () => { + const jsonish = fc.string({ maxLength: 12, unit: fc.constantFrom(...'{}[],:"\\ \n-+.0123456789eEtruefalsn/bu\u0001x') }) + fc.assert( + fc.property(jsonish, (text) => { + let accepted = true + try { + JSON.parse(text) + } catch { + accepted = false + } + assert.equal(parseJsonText(text) !== undefined, accepted) + }), + propertyRuns(2000), + ) +}) diff --git a/src/json-text.ts b/src/json-text.ts new file mode 100644 index 0000000..ec5eefb --- /dev/null +++ b/src/json-text.ts @@ -0,0 +1,124 @@ +import type { JsonValue } from './json-value.ts' + +type Frame = { entries: [string, JsonValue][]; key: string; kind: 'object' } | { items: JsonValue[]; kind: 'array' } + +type Scanned = { end: number; value: T } | undefined + +const escapes = new Map([ + ['"', '"'], + ['/', '/'], + ['\\', '\\'], + ['b', '\b'], + ['f', '\f'], + ['n', '\n'], + ['r', '\r'], + ['t', '\t'], +]) +const hexDigits = /[0-9A-Fa-f]{4}/y +const literals = new Map([ + ['false', false], + ['null', null], + ['true', true], +]) +const numberSyntax = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[Ee][+-]?[0-9]+)?/y + +// In place of JSON.parse: V8 reads an escaped key against keys earlier parses saw (https://issues.chromium.org/issues/521080746). +export function parseJsonText(text: string): { value: JsonValue } | undefined { + const frames: Frame[] = [] + let index = skipWhitespace(text, 0) + for (;;) { + const opened = openValue(text, index, frames) + if (opened === undefined) return undefined + let { end, value } = opened + if (value === undefined) { + index = end + continue + } + for (;;) { + index = skipWhitespace(text, end) + const frame = frames[frames.length - 1] + if (frame === undefined) return index === text.length ? { value } : undefined + if (frame.kind === 'array') frame.items.push(value) + else frame.entries.push([frame.key, value]) + const separator = text.charAt(index) + if (separator === ',') break + if (separator !== (frame.kind === 'array' ? ']' : '}')) return undefined + frames.pop() + end = index + 1 + value = frame.kind === 'array' ? frame.items : Object.fromEntries(frame.entries) + } + const frame = frames[frames.length - 1] + index = skipWhitespace(text, index + 1) + if (frame?.kind === 'object') { + const key = readKey(text, index) + if (key === undefined) return undefined + frame.key = key.value + index = key.end + } + } +} + +// A value whose `value` is undefined opened a container, and `end` is where its first member starts. +function openValue(text: string, index: number, frames: Frame[]): Scanned { + const opener = text.charAt(index) + if (opener !== '[' && opener !== '{') return readScalar(text, index) + const first = skipWhitespace(text, index + 1) + if (text.charAt(first) === (opener === '[' ? ']' : '}')) return { end: first + 1, value: opener === '[' ? [] : {} } + if (opener === '[') { + frames.push({ items: [], kind: 'array' }) + return { end: first, value: undefined } + } + const key = readKey(text, first) + if (key === undefined) return undefined + frames.push({ entries: [], key: key.value, kind: 'object' }) + return { end: key.end, value: undefined } +} + +function readKey(text: string, index: number): Scanned { + const key = readString(text, index) + if (key === undefined) return undefined + const colon = skipWhitespace(text, key.end) + return text.charAt(colon) === ':' ? { end: skipWhitespace(text, colon + 1), value: key.value } : undefined +} + +function readScalar(text: string, index: number): Scanned { + if (text.charAt(index) === '"') return readString(text, index) + for (const [spelling, value] of literals) if (text.startsWith(spelling, index)) return { end: index + spelling.length, value } + numberSyntax.lastIndex = index + const number = numberSyntax.exec(text)?.[0] + return number === undefined ? undefined : { end: index + number.length, value: Number(number) } +} + +function readString(text: string, start: number): Scanned { + if (text.charAt(start) !== '"') return undefined + const pieces: string[] = [] + let from = start + 1 + let index = from + while (index < text.length) { + const code = text.charCodeAt(index) + if (code === 0x22) { + pieces.push(text.slice(from, index)) + return { end: index + 1, value: pieces.join('') } + } + if (code < 0x20) return undefined + if (code !== 0x5c) { + index += 1 + continue + } + pieces.push(text.slice(from, index)) + const escape = text.charAt(index + 1) + hexDigits.lastIndex = index + 2 + const decoded = escape === 'u' ? (hexDigits.test(text) ? String.fromCharCode(parseInt(text.slice(index + 2, index + 6), 16)) : undefined) : escapes.get(escape) + if (decoded === undefined) return undefined + pieces.push(decoded) + index += escape === 'u' ? 6 : 2 + from = index + } + return undefined +} + +function skipWhitespace(text: string, index: number): number { + let end = index + while (end < text.length && ' \t\n\r'.includes(text.charAt(end))) end += 1 + return end +} diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index c3b6696..7e5c28a 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -5,6 +5,7 @@ import { backslashEscape, holdsNullCharacter } from './commonmark/grammar.ts' import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts' import { isJsonValue, overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' +import { parseJsonText } from '../json-text.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue } @@ -330,10 +331,6 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value } function parseJson(raw: string): JsonValue | undefined { - try { - const value: unknown = JSON.parse(raw) - return isJsonValue(value) ? value : undefined - } catch { - return undefined - } + const value = parseJsonText(raw)?.value + return value !== undefined && isJsonValue(value) ? value : undefined } diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 55e88cf..6f00b52 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -9,6 +9,7 @@ import { infoStringCarries } from './commonmark/grammar.ts' import { isAdfNode } from '../adf/document.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' +import { parseJsonText } from '../json-text.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export const carryFencePrefix = 'adf:' @@ -91,12 +92,3 @@ function typedValue(value: JsonValue, type: string): Read { if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well; ${fenceEscape(type)}`) } return { value: { ...value, type } } } - -function parseJsonText(raw: string): { value: unknown } | undefined { - try { - const value: unknown = JSON.parse(raw) - return { value } - } catch { - return undefined - } -}