mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Answer stability round 1: 0 🔴, 7 🟡 (0 misread); drop adfDocumentFault, wrap readAdfDocument in the engine limits, pin the budget, key order and path, one test per case
This commit is contained in:
+61
-26
@@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+9
-12
@@ -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<AdfDocument> {
|
||||
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<AdfDocument> {
|
||||
@@ -67,7 +64,7 @@ function shapedDocument(value: unknown): Read<AdfDocument> {
|
||||
|
||||
// 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<readonly AdfNode[] | undefined> {
|
||||
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)
|
||||
|
||||
@@ -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<unknown> {
|
||||
const root: unknown[] = []
|
||||
const pending: Step[] = [{ depth: 0, key: 0, source: value, target: root }]
|
||||
@@ -44,13 +45,13 @@ export function plainCopy(value: unknown): Read<unknown> {
|
||||
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<Held> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user