3
0
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:
2026-11-09 07:38:07 +01:00
committed by Lillem4n
parent ca6016b4b6
commit d255b17d37
3 changed files with 75 additions and 42 deletions
+61 -26
View File
@@ -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
View File
@@ -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)
+5 -4
View File
@@ -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)