fix(tui): harden Mermaid rendering limits (#42123)

This commit is contained in:
Kit Langton 2026-08-12 15:06:28 -04:00 committed by GitHub
parent c7bee09632
commit c6a86acb0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 70 additions and 6 deletions

View file

@ -4,7 +4,7 @@ import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from ".
describe("DiagramCanvas", () => {
test("rejects canvases that exceed the rendering budget", () => {
expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
expect(() => new DiagramCanvas(1_000, 251)).toThrow(DiagramCanvasSizeError)
})
test("rejects invalid canvas dimensions", () => {
@ -38,6 +38,12 @@ describe("DiagramCanvas", () => {
expect(stringWidth(canvas.toString())).toBe(4)
})
test("does not emit partial wide graphemes", () => {
const clipped = new DiagramCanvas<"label">(1, 1)
clipped.setText(0, 0, "界", "label")
expect(clipped.toString()).toBe("")
})
test("keeps custom measurement for ASCII text", () => {
let measurements = 0
const canvas = new DiagramCanvas<"label">(5, 1, {

View file

@ -40,7 +40,7 @@ export interface DiagramCanvasRunOptions<Style extends string, Metadata extends
trimBottom?: boolean
}
const MAX_DIAGRAM_CELLS = 1_000_000
const MAX_DIAGRAM_CELLS = 250_000
export class DiagramCanvasSizeError extends Error {
constructor(
@ -122,7 +122,6 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
merge: boolean,
): void {
if (y < 0 || y >= this.cells.length || x < 0 || x >= this.cells[y]!.length) return
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
const cell = merge ? (this.mergeCell?.(this.cells[y]![x]!, incoming) ?? incoming) : incoming
this.cells[y]![x] = cell
@ -151,6 +150,10 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
let offset = 0
for (const grapheme of diagramTextGraphemes(text)) {
const width = Math.max(1, this.measure(grapheme))
if (x + offset < 0 || x + offset + width > this.width) {
offset += width
continue
}
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
for (let continuation = 1; continuation < width; continuation++) {
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))

View file

@ -30,6 +30,7 @@ const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
const CIRCLE_NODE_RE = new RegExp(`^${ID_RE}\\(\\(.+\\)\\)$`)
const MAX_FLOWCHART_LINE_LENGTH = 10_000
const EDGE_OPERATOR_RE =
/(-\.(?!->)(.+?)\.(?:->|-))|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/dg
@ -252,6 +253,9 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
if (line.length > MAX_FLOWCHART_LINE_LENGTH) {
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, "Flowchart statement is too long")
}
if (hasInternalStatementSeparator(line)) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const header = line.match(FLOWCHART_HEADER_RE)
if (header) {

View file

@ -37,6 +37,8 @@ interface PreparedDiagram {
export interface MermaidMarkdownRendererOptions {
compact?: boolean
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
layoutMaxWidth?: number
colors?: {
text?: ColorInput
primary?: ColorInput
@ -101,11 +103,19 @@ class StaticDiagramRenderable extends TextRenderable {
}
}
function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
function prepareDiagram(
kind: DiagramKind,
source: string,
options: MermaidMarkdownRendererOptions,
layoutMaxWidth: number,
): PreparedDiagram {
const colors = options.colors ?? {}
switch (kind) {
case "flowchart": {
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), {
compact: options.compact,
layoutMaxWidth,
})
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
return {
kind,
@ -192,9 +202,11 @@ export function createMermaidCodeBlockRenderer(
// OpenTUI's default block ID is the stable identity available for this fence across streaming updates.
const key = context.defaultRender()?.id
const options = typeof input === "function" ? input() : input
const configuredMaxWidth = options.layoutMaxWidth === undefined ? 120 : Math.max(1, Math.trunc(options.layoutMaxWidth))
const layoutMaxWidth = Math.min(configuredMaxWidth, Math.max(1, Math.trunc(ctx.width)))
try {
const prepared = prepareDiagram(kind, token.text, options)
const prepared = prepareDiagram(kind, token.text, options, layoutMaxWidth)
const diagram = new StaticDiagramRenderable(ctx, prepared)
if (key) claimLastGood(key, prepared, diagram, lastGood)
return diagram

View file

@ -23,6 +23,7 @@ const ELSE_RE = /^else(?:\s+(.+))?$/i
const LOOP_RE = /^loop\s+(.+)$/i
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
const UNSUPPORTED_BIDIRECTIONAL_MESSAGE_RE = /<<-{1,2}>>/
const MESSAGE_OPERATOR_RE = /-->>|->>|--x|-x|--\)|-\)|-->|->/
const CSS_COLOR_NAMES = new Set([
"black",
"white",
@ -237,6 +238,9 @@ export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
const arrow = messageMatch[2]!
const activationMarker = messageMatch[3]!
const to = stripQuotes(messageMatch[4]!)
if (MESSAGE_OPERATOR_RE.test(from) || MESSAGE_OPERATOR_RE.test(to)) {
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
}
const message: SequenceMessage = {
from,
to,

View file

@ -176,6 +176,9 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
if (transitionMatch) {
const rawFrom = transitionMatch[1]!
const rawTo = transitionMatch[2]!
if (rawFrom.includes("-->") || rawTo.includes("-->")) {
throw new MermaidSyntaxError("state", source.lineNumber, line)
}
const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)

View file

@ -41,6 +41,11 @@ describe("parser diagnostics", () => {
expect(diagram.edges).toHaveLength(1)
})
test("rejects pathological flowchart statements before parsing edge operators", () => {
const statement = "-.a".repeat(4_000)
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow("Flowchart statement is too long")
})
test("exposes structured syntax errors through top-level rendering", () => {
try {
renderSequenceDiagram(`sequenceDiagram
@ -62,6 +67,11 @@ describe("parser diagnostics", () => {
}
})
test("rejects chained sequence and state transitions instead of creating phantom endpoints", () => {
expect(() => parseMermaidSequenceDiagram("sequenceDiagram\n A->>B->>C: hello")).toThrow(MermaidSyntaxError)
expect(() => parseMermaidStateDiagram("stateDiagram-v2\n A-->B-->C")).toThrow(MermaidSyntaxError)
})
test("reports unclosed state constructs at their opening line", () => {
expect(() =>
parseMermaidStateDiagram(`stateDiagram-v2

View file

@ -289,6 +289,28 @@ flowchart TB
expect(frame).not.toMatch(/<\/?i>|<br|events persist|mcp stdio/)
})
test("folds a horizontal flowchart to fit the Markdown viewport", async () => {
const testRenderer = await createTestRenderer({ width: 160, height: 30 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-horizontal-flowchart",
content: `\`\`\`mermaid
flowchart LR
A["per TURN<br/>fresh sandbox each turn"] --- B["per SESSION/thread<br/>one sandbox per Slack thread"] --- C["per REPO<br/>threads share a sandbox"] --- D["GLOBAL registry<br/>(upstream today: hardwired at boot)"]
\`\`\``,
syntaxStyle,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
const diagram = markdown.getChildren()[0] as CodeRenderable
expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
expect(diagram.scrollWidth).toBeLessThanOrEqual(120)
expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
})
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer