diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 137797a..daa8741 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -5,12 +5,17 @@ import vm from 'node:vm' import type { AdfDocument, AdfNode } from './document.ts' import type { JsonValue } from '../json-value.ts' import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown } from '../index.ts' -import { adfDocumentFault, readAdfDocument, writableContent } from './document.ts' +import { readAdfDocument, writableContent } from './document.ts' import { largestNesting } from '../nesting.ts' -import { mostValues } from './plain-copy.ts' + +function written(value: unknown): { code: string; message: string } { + const read = readAdfDocument(value) + const checked = read.ok ? writableContent(read.value) : read + return checked.ok ? { code: 'accepted', message: 'accepted' } : checked.error +} function fault(value: unknown): string { - return adfDocumentFault(value)?.message ?? 'accepted' + return written(value).message } function readable(value: unknown): boolean { @@ -23,7 +28,7 @@ function readFault(value: unknown): string { } function faultCode(value: unknown): string { - return adfDocumentFault(value)?.code ?? 'accepted' + return written(value).code } function nested(levels: number): JsonValue { @@ -132,7 +137,6 @@ test('opens every writer with the same checks: a document, version 1, and no mor const result = writableContent(document) return result.ok ? JSON.stringify(result.value) ?? 'no content key' : result.error.code } - assert.equal(checked({ type: 'doc', version: Number.NaN }), 'not-an-adf-document') assert.equal(checked({ type: 'doc', version: 2 }), 'unsupported-document-version') assert.equal(checked({ type: 'doc', version: 1 }), 'no content key') assert.equal(checked({ content: [{ type: 'rule' }], type: 'doc', version: 1 }), '[{"type":"rule"}]') @@ -257,19 +261,25 @@ test('refuses a getter minting a fresh child per access without walking it', () assert.equal(readFault(documentOf(endless())), 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter') }) -test('refuses a Proxy that throws, and a revoked one, wherever they stand', () => { - const unreadable = 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw' - const throwing = (target: object, trap: string): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } }) - for (const trap of ['getOwnPropertyDescriptor', 'getPrototypeOf', 'ownKeys']) { - assert.equal(readFault(throwing({ type: 'doc', version: 1 }, trap)), unreadable, trap) - assert.equal(readFault(documentOf(throwing({ b: 1 }, trap))), unreadable, trap) - } +const unreadable = 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw' + +for (const trap of ['getOwnPropertyDescriptor', 'getPrototypeOf', 'ownKeys']) { + test(`refuses a Proxy whose ${trap} trap throws, as the document and inside it`, () => { + const throwing = (target: object): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } }) + assert.equal(readFault(throwing({ type: 'doc', version: 1 })), unreadable) + assert.equal(readFault(documentOf(throwing({ b: 1 }))), unreadable) + }) +} + +test('refuses a Proxy that throws on an array, and a revoked one, wherever they stand', () => { + const throwing = (target: unknown[], trap: string): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } }) assert.equal(readFault({ content: throwing([1], 'getOwnPropertyDescriptor'), type: 'doc', version: 1 }), unreadable) assert.equal(readFault({ content: throwing([], 'get'), type: 'doc', version: 1 }), unreadable) const revoked = Proxy.revocable({}, {}) revoked.revoke() assert.equal(readFault(revoked.proxy), unreadable) assert.equal(readFault(documentOf(revoked.proxy)), unreadable) + assert.deepEqual(readAdfDocument(revoked.proxy), { error: { code: 'not-an-adf-document', message: unreadable.slice('not-an-adf-document: '.length), path: [] }, ok: false }) }) test('copies own enumerable string keys alone, as JSON.stringify does', () => { @@ -284,6 +294,13 @@ test('copies own enumerable string keys alone, as JSON.stringify does', () => { assert.deepEqual(dropped.value.content?.[0]?.attrs, {}) }) +test('copies the keys of an object in the order the original holds them', () => { + const result = readAdfDocument(documentOf({ b: 1, a: 2, c: { z: 1, y: 2 } })) + assert.ok(result.ok) + const attrs = result.value.content?.[0]?.attrs + assert.equal(JSON.stringify(attrs), '{"b":1,"a":2,"c":{"z":1,"y":2}}') +}) + test('copies a key named __proto__ as a key, leaving the copy a plain object', () => { const result = readAdfDocument(documentOf(JSON.parse('{"__proto__":{"polluted":1}}'))) assert.ok(result.ok) @@ -291,6 +308,7 @@ test('copies a key named __proto__ as a key, leaving the copy a plain object', ( assert.deepEqual(Object.keys(attrs ?? {}), ['__proto__']) assert.equal(Object.getPrototypeOf(attrs), Object.prototype) assert.equal(Reflect.get(attrs ?? {}, 'polluted'), undefined) + assert.deepEqual(Object.getOwnPropertyDescriptor(attrs ?? {}, '__proto__'), { configurable: true, enumerable: true, value: { polluted: 1 }, writable: true }) }) test('reads an array by index, so no iterator runs and a hole is no JSON value', () => { @@ -301,23 +319,40 @@ test('reads an array by index, so no iterator runs and a hole is no JSON value', assert.equal(readable(documentOf({ a: [1, , 3] })), false) }) -test('refuses an array claiming more elements than the values left, before it reads any', () => { - const claimed = `unsupported-node-shape: an ADF document holds at most ${mostValues} JSON values: this one holds more` - const claiming = (length: unknown): object => new Proxy([], { get: (target, key) => (key === 'length' ? length : Reflect.get(target, key)) }) - assert.equal(readFault({ content: claiming(2 ** 32 - 1), type: 'doc', version: 1 }), claimed) - assert.equal(readFault({ content: new Array(mostValues - 1), type: 'doc', version: 1 }), claimed) - assert.equal(readFault({ content: claiming('many'), type: 'doc', version: 1 }), 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw') - assert.equal(readFault({ content: claiming(-1), type: 'doc', version: 1 }), 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw') +const claimed = 'unsupported-node-shape: an ADF document holds at most 10000000 JSON values: this one holds more' +const claiming = (length: unknown): object => new Proxy([], { + get: (target, key) => (key === 'length' ? length : Reflect.get(target, key)), + getOwnPropertyDescriptor: () => { throw new Error('trap') }, }) -test('returns a result for a hostile document from every writer', () => { - const throwing = new Proxy({ type: 'rule' }, { ownKeys: () => { throw new Error('trap') } }) - const hostile: AdfDocument[] = [{ get content() { return [] }, type: 'doc', version: 1 }, { content: [throwing], type: 'doc', version: 1 }] - for (const write of writers) { - for (const document of hostile) { +test('refuses an array claiming more elements than the values left, before it reads any', () => { + assert.equal(readFault({ content: claiming(2 ** 32 - 1), type: 'doc', version: 1 }), claimed) + assert.equal(readFault({ content: new Array(10_000_000 - 1), type: 'doc', version: 1 }), claimed) +}) + +test('reads an array of the most values the document may hold, and refuses one value more', () => { + assert.equal(readFault({ content: claiming(10_000_000 - 4), type: 'doc', version: 1 }), unreadable) + assert.equal(readFault({ content: claiming(10_000_000 - 3), type: 'doc', version: 1 }), claimed) +}) + +test('refuses an array whose length is no count', () => { + const badLength = "not-an-adf-document: an ADF document is plain data: an array's length is not a count" + assert.equal(readFault({ content: claiming('many'), type: 'doc', version: 1 }), badLength) + assert.equal(readFault({ content: claiming(-1), type: 'doc', version: 1 }), badLength) + assert.equal(readFault({ content: claiming(1.5), type: 'doc', version: 1 }), badLength) +}) + +const hostile: [string, AdfDocument][] = [ + ['a getter', { get content() { return [] }, type: 'doc', version: 1 }], + ['a throwing Proxy', { content: [new Proxy({ type: 'rule' }, { ownKeys: () => { throw new Error('trap') } })], type: 'doc', version: 1 }], +] + +for (const write of writers) { + for (const [name, document] of hostile) { + test(`${write.name} refuses a document holding ${name}`, () => { const result = write(document) assert.ok(!result.ok) assert.equal(result.error.code, 'not-an-adf-document') - } + }) } -}) +} diff --git a/src/adf/document.ts b/src/adf/document.ts index a7ea4d1..b05d53c 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -4,7 +4,7 @@ import { inlineNodeModel } from './inline-nodes.ts' import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { largestNesting } from '../nesting.ts' import { plainCopy } from './plain-copy.ts' -import { withinLongest } from '../length-bound.ts' +import { withinEngineLimits, withinLongest } from '../length-bound.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export type AdfAttributes = { [key: string]: JsonValue } @@ -34,17 +34,14 @@ const documentKeys = ['content', 'type', 'version'] const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] -export function adfDocumentFault(value: unknown): ConvertFault | undefined { - const shaped = shapedDocument(value) - return shaped.fault ?? nestingFault(shaped.value.content ?? []) -} - -// Reads `value` once into a plain copy and checks the copy's shape. Depth is the walks' business, not the shape's: a deep document reads as blocks and marks do. +// Reads a document nested past 500 levels too: the converters refuse that depth, as they do a block's or a mark's. export function readAdfDocument(value: unknown): Result { - const copy = plainCopy(value) - if (copy.fault !== undefined) return faulted(copy.fault, []) - const shaped = shapedDocument(copy.value) - return shaped.fault === undefined ? success(shaped.value) : faulted(shaped.fault, []) + return withinEngineLimits(() => { + const copy = plainCopy(value) + if (copy.fault !== undefined) return faulted(copy.fault, []) + const shaped = shapedDocument(copy.value) + return shaped.fault === undefined ? success(shaped.value) : faulted(shaped.fault, []) + }) } function shapedDocument(value: unknown): Read { @@ -67,7 +64,7 @@ function shapedDocument(value: unknown): Read { // The checks every writer opens with; `undefined` where the document holds no `content` key, which each format spells its own way. export function writableContent(document: AdfDocument): Result { - const fault = adfDocumentFault(document) + const fault = nestingFault(document.content ?? []) if (fault !== undefined) return faulted(fault, []) if (document.version !== 1) return failure('unsupported-document-version', `no format this library writes carries ADF version ${document.version}`, []) if (document.content === undefined) return success(undefined) diff --git a/src/adf/plain-copy.ts b/src/adf/plain-copy.ts index 69fb2c3..fd2b1af 100644 --- a/src/adf/plain-copy.ts +++ b/src/adf/plain-copy.ts @@ -2,7 +2,7 @@ import type { ConvertFault, Read } from '../result.ts' import { isPlainObject } from '../json-value.ts' // Each object, array, string, number, boolean and null counts one: far past any real page, and it stops a Proxy claiming unbounded width. -export const mostValues = 10_000_000 +const mostValues = 10_000_000 // An object that is no plain object is copied with this prototype, so `isJsonValue` refuses the copy as it refuses the original. const unplain = {} @@ -10,12 +10,13 @@ const unplain = {} type Step = { depth: number; key: number | string; source: unknown; target: object } type Held = { entries: [number | string, unknown][]; kind: 'array' | 'object' | 'unplain' } +const badLength: ConvertFault = { code: 'not-an-adf-document', message: "an ADF document is plain data: an array's length is not a count" } const holdsItself: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is a tree: an object in it holds itself' } const tooMany: ConvertFault = { code: 'unsupported-node-shape', message: `an ADF document holds at most ${mostValues} JSON values: this one holds more` } const unreadable: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is plain data: reading one of its objects threw' } const withAccessor: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is plain data: one of its objects holds a getter or a setter' } -// Every later step reads the copy, never the caller's object: a getter, a Proxy trap or an iterator of theirs runs at most once here, and never an accessor. +// Reads the caller's object through property descriptors alone, so no getter runs and a Proxy's traps run once for each place the object is held; every later step reads the copy. export function plainCopy(value: unknown): Read { const root: unknown[] = [] const pending: Step[] = [{ depth: 0, key: 0, source: value, target: root }] @@ -44,13 +45,13 @@ export function plainCopy(value: unknown): Read { return { value: root[0] } } -// The only reads of the caller's object, so the only `try`: a throw there is the caller's Proxy. +// The only reads of the caller's object, so the only `try`: a throw there is the caller's Proxy, or an engine out of memory that this cannot tell apart from it. function heldBy(source: object, left: number): Read { try { const entries: [number | string, unknown][] = [] if (Array.isArray(source)) { const length: unknown = source.length - if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) return { fault: unreadable } + if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) return { fault: badLength } if (length > left) return { fault: tooMany } for (let index = 0; index < length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(source, index)