diff --git a/AGENTS.md b/AGENTS.md index 80494a0..ec04b15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,9 +109,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga `Result` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws. `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on the spot. -- Nothing recurses unbounded: the document guard walks iteratively and block emission refuses past - 500 nesting levels, so a deep document is a `Result` rather than the stack overflow that waits - near 3000. +- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values + are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that + waits near 2000. - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states unrepresentable. diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index e6d25ea..04a212d 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -73,6 +73,8 @@ test('refuses a link destination CommonMark cannot spell', () => { assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link-destination') assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&b=2'))), 'unspellable-link-destination') assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination') + const entity = 'https://example.com/?a=1&b=2' + assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link-destination') assert.equal(markdown(adfToMarkdown(link('https://en.example.com/a_(b)'))), '[t](https://en.example.com/a_(b))\n') }) @@ -100,6 +102,7 @@ test('refuses whitespace CommonMark cannot hold', () => { assert.equal(code(adfToMarkdown(document(paragraph({ text: ' lead', type: 'text' })))), 'unspellable-whitespace') assert.equal(code(adfToMarkdown(document(paragraph({ text: 'trail ', type: 'text' })))), 'unspellable-whitespace') assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\nb', type: 'text' })))), 'unspellable-whitespace') + assert.equal(code(adfToMarkdown(document(paragraph({ text: '\fa', type: 'text' })))), 'unspellable-whitespace') assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'em' }], text: 'x ', type: 'text' }, { text: 'y', type: 'text' })))), 'unspellable-whitespace') }) @@ -177,13 +180,34 @@ test('wraps adjacent nodes carrying one mark once, and a differing mark twice', assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n') }) +test('refuses a mark spelling that cannot open or close where it sits', () => { + const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) + const strong: AdfMark = { type: 'strong' } + assert.equal(code(adfToMarkdown(document(paragraph({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' })))), 'unspellable-mark') + assert.equal(code(adfToMarkdown(document(paragraph(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' })))), 'unspellable-mark') + assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('.a', strong))))), 'unspellable-mark') + assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n') + assert.equal(emitted(marked('a.', strong)), '**a.**\n') + assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n') +}) + +test('refuses marks and attributes nested deeper than the emitter carries', () => { + const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' })) + assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-node-shape') + let attrs: AdfMark['attrs'] = { depth: 'x' } + for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } + assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), 'not-an-adf-document') +}) + test('escapes a literal delimiter that would merge with an emitted one', () => { const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) assert.equal(emitted(marked('a_', { type: 'em' })), '_a\\__\n') assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n') assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n') - assert.equal(emitted({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' })), 'x~~\\~a~~\n') + assert.equal(emitted(marked('~a', { type: 'strike' })), '~~\\~a~~\n') + assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' }))))), 'unspellable-mark') assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n') assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n') assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\![x](https://example.com/)\n') @@ -207,6 +231,7 @@ test('refuses the characters CommonMark rewrites', () => { assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), 'unspellable-whitespace') assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character') assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character') + assert.equal(code(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape') }) test('refuses a text node carrying no text at all', () => { diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index 99bf1d7..ca49af6 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -4,10 +4,10 @@ import { emitInlineLine } from './markdown-inline.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { isAdfDocument } from './adf-document.ts' +import { largestNesting } from './nesting.ts' import { longestBacktickRun } from './backtick-runs.ts' const largestListMarker = 999999999 -const largestNesting = 500 const listTypes = ['bulletList', 'orderedList'] export function adfToMarkdown(document: AdfDocument): Result { @@ -72,7 +72,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { let text = '' for (const [index, child] of (node.content ?? []).entries()) { const childPath = [...path, 'content', index] - if (child.type !== 'text' || typeof child.text !== 'string' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) { + if (child.type !== 'text' || typeof child.text !== 'string' || child.text === '' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) { return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', childPath) } if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath) diff --git a/src/json-value.ts b/src/json-value.ts index 6762f15..88b548a 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -1,17 +1,22 @@ +import { largestNesting } from './nesting.ts' + export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } export function isJsonValue(value: unknown): value is JsonValue { - const pending: unknown[] = [value] + const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] while (pending.length > 0) { - const item = pending.pop() + const entry = pending.pop() + if (entry === undefined) continue + const { depth, item } = entry + if (depth > largestNesting) return false if (item === null || typeof item === 'boolean' || typeof item === 'string') continue if (typeof item === 'number') { if (!Number.isFinite(item)) return false continue } // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. - if (Array.isArray(item)) pending.push(...item) - else if (typeof item === 'object') pending.push(...Object.values(item)) + if (Array.isArray(item)) for (const child of [...item]) pending.push({ depth: depth + 1, item: child }) + else if (typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child }) else return false } return true diff --git a/src/markdown-escaping.ts b/src/markdown-escaping.ts index 7e13931..034d007 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown-escaping.ts @@ -5,9 +5,11 @@ export type InlineSegment = { text: string } +export type AssembledLine = { line: string; unspellableDelimiter: string | undefined } + export type LineContainer = 'heading' | 'paragraph' -const delimiters = ['`', '*', '_', '~'] +const delimiters = ['*', '_', '`', '~'] const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] @@ -15,7 +17,7 @@ const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/ const linkOpener = /\](?=[([:])/ const unicodePunctuation = /[\p{P}\p{S}]/u -export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): string { +export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { return escape(resolveEmphasis(segments), container) } @@ -40,6 +42,7 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { if (openerIndex === undefined || opener === undefined) continue const openOffset = offsets[openerIndex] ?? 0 const closeOffset = offsets[index] ?? 0 + if (opener.text !== '_') continue if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue opener.text = '*' segment.text = '*' @@ -47,11 +50,12 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { return resolved } -function escape(segments: readonly InlineSegment[], container: LineContainer): string { +function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { const scan = segments.map((segment) => segment.text).join('') const kinds: InlineSegment['kind'][] = [] for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) kinds.push(segment.kind) const escaped = new Set() + const placements: number[] = [] let output = '' for (let index = 0; index < scan.length; index += 1) { const kind = kinds[index] @@ -60,9 +64,24 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): s output += '\\' escaped.add(index) } + placements.push(output.length) output += scan.charAt(index) } - return output + return { line: output, unspellableDelimiter: unspellableDelimiter(segments, output, placements) } +} + +function unspellableDelimiter(segments: readonly InlineSegment[], output: string, placements: readonly number[]): string | undefined { + let cursor = 0 + for (const segment of segments) { + const start = placements[cursor] ?? 0 + cursor += segment.text.length + if (segment.kind !== 'emphasis-close' && segment.kind !== 'emphasis-open') continue + const before = charAt(output, start - 1) + const after = output.charAt(start + segment.text.length) + const spellable = segment.kind === 'emphasis-open' ? isLeftFlanking(before, after) : isRightFlanking(before, after) + if (!spellable) return segment.text + } + return undefined } function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean { diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 9547a9e..d92f68c 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -1,5 +1,6 @@ import type { AdfMark, AdfNode } from './adf-document.ts' import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts' +import { largestNesting } from './nesting.ts' import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { longestBacktickRun } from './backtick-runs.ts' @@ -19,9 +20,13 @@ const linkAttributes = ['href', 'title'] export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path }) if (!segments.ok) return segments - const line = assembleInlineLine(segments.value, container) + const assembled = assembleInlineLine(segments.value, container) + if (assembled.unspellableDelimiter !== undefined) { + return failure('unspellable-mark', `the ${assembled.unspellableDelimiter} spelling cannot open or close where it sits`, path) + } + const line = assembled.line for (const [index, single] of line.split('\n').entries()) { - if (/^[ \t]|[ \t]$/.test(single)) { + if (/^[ \t\v\f]|[ \t\v\f]$/.test(single)) { return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path) } if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) { @@ -32,6 +37,9 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain } function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result { + if (depth > largestNesting) { + return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path) + } const runs = inlineRuns(nodes, depth, firstIndex) const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { @@ -94,10 +102,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, if (!inner.ok) return inner const text = inner.value.map((segment) => segment.text).join('') if (holdsEdgeWhitespace(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path) - if (mark.type === 'em') { - return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }]) - } - return success([{ kind: 'syntax', text: spelling }, ...inner.value, { kind: 'syntax', text: spelling }]) + return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }]) } function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result { @@ -115,7 +120,7 @@ function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErr } function holdsEdgeWhitespace(text: string): boolean { - return text !== '' && (isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1))) + return isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1)) } function needsPadding(text: string): boolean { diff --git a/src/nesting.ts b/src/nesting.ts new file mode 100644 index 0000000..1019551 --- /dev/null +++ b/src/nesting.ts @@ -0,0 +1 @@ +export const largestNesting = 500