From e8c8ddec8a02faa6621624d797190c62caae8056 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 13 May 2026 11:53:09 +0100 Subject: [PATCH] fix(wiki): sanitize generated mermaid diagrams (#1539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(wiki): sanitize generated mermaid diagrams * fix(wiki): address mermaid sanitizer review --------- Co-authored-by: Gergő Magyar --- gitnexus/src/core/wiki/generator.ts | 9 +- gitnexus/src/core/wiki/html-viewer.ts | 3 +- gitnexus/src/core/wiki/mermaid-sanitizer.ts | 119 ++++++++++++++++++ gitnexus/test/integration/cli-e2e.test.ts | 56 ++++----- .../test/unit/wiki-mermaid-sanitizer.test.ts | 97 ++++++++++++++ 5 files changed, 248 insertions(+), 36 deletions(-) create mode 100644 gitnexus/src/core/wiki/mermaid-sanitizer.ts create mode 100644 gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index 9f65d26e8..dc9c2e74e 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -28,6 +28,7 @@ import { type FileWithExports, } from './graph-queries.js'; import { generateHTMLViewer } from './html-viewer.js'; +import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js'; import { callLLM, @@ -591,7 +592,7 @@ export class WikiGenerator { const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name)); // Write page with front matter - const pageContent = `# ${node.name}\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`); await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8'); } @@ -631,7 +632,7 @@ export class WikiGenerator { const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name)); - const pageContent = `# ${node.name}\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`); await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8'); } @@ -681,7 +682,9 @@ export class WikiGenerator { this.streamOpts('Generating overview', 88), ); - const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown( + `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`, + ); await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8'); } diff --git a/gitnexus/src/core/wiki/html-viewer.ts b/gitnexus/src/core/wiki/html-viewer.ts index f961d36ae..c0fd7323a 100644 --- a/gitnexus/src/core/wiki/html-viewer.ts +++ b/gitnexus/src/core/wiki/html-viewer.ts @@ -7,6 +7,7 @@ import fs from 'fs/promises'; import path from 'path'; +import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js'; interface ModuleTreeNode { name: string; @@ -42,7 +43,7 @@ export async function generateHTMLViewer(wikiDir: string, projectName: string): const dirEntries = await fs.readdir(wikiDir); for (const f of dirEntries.filter((f) => f.endsWith('.md'))) { const content = await fs.readFile(path.join(wikiDir, f), 'utf-8'); - pages[f.replace(/\.md$/, '')] = content; + pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content); } const html = buildHTML(projectName, moduleTree, pages, meta); diff --git a/gitnexus/src/core/wiki/mermaid-sanitizer.ts b/gitnexus/src/core/wiki/mermaid-sanitizer.ts new file mode 100644 index 000000000..c8d443c78 --- /dev/null +++ b/gitnexus/src/core/wiki/mermaid-sanitizer.ts @@ -0,0 +1,119 @@ +const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g; +const NODE_LABEL_RE = + /(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g; +const EDGE_LABEL_RE = /\|([^|\n]+)\|/g; +const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/; +const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/; +const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/; + +const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/; +const EDGE_RE = + /(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g; + +export function sanitizeMermaidMarkdown(markdown: string): string { + return markdown.replace(MERMAID_FENCE_RE, (_match, diagram: string) => { + return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```'; + }); +} + +export function sanitizeMermaidDiagram(diagram: string): string { + const aliases = new Map(); + let nextAlias = 1; + + const aliasFor = (id: string): string => { + const existing = aliases.get(id); + if (existing) return existing; + + const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node'; + let alias = base; + while ([...aliases.values()].includes(alias)) { + nextAlias += 1; + alias = `${base}_${nextAlias}`; + } + aliases.set(id, alias); + return alias; + }; + + return diagram + .split('\n') + .map((line) => sanitizeMermaidLine(line, aliasFor)) + .join('\n'); +} + +function sanitizeMermaidLine(line: string, aliasFor: (id: string) => string): string { + let sanitized = replaceLiteralLineBreaksInLabels(line); + sanitized = quoteUnsafeEdgeLabels(sanitized); + + const prefixMatch = sanitized.match(LINE_PREFIX_RE); + if (!prefixMatch) return sanitized; + + const prefix = prefixMatch[1]; + const body = prefixMatch[2]; + if (isDirectiveLine(body)) return sanitized; + + const parts = body.split(EDGE_RE); + if (parts.length === 1) return sanitized; + + for (let i = 0; i < parts.length; i += 2) { + parts[i] = sanitizeNodeReference(parts[i], aliasFor); + } + + return prefix + parts.join(''); +} + +function replaceLiteralLineBreaksInLabels(line: string): string { + return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '
')); +} + +function quoteUnsafeEdgeLabels(line: string): string { + return line.replace(EDGE_LABEL_RE, (match, label: string) => { + const trimmed = label.trim(); + if (!UNSAFE_EDGE_LABEL_RE.test(trimmed)) return match; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return match; + } + return `|"${escapeMermaidLabel(trimmed)}"|`; + }); +} + +function sanitizeNodeReference(segment: string, aliasFor: (id: string) => string): string { + const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/); + if (!match) return segment; + + const [, leading, id, suffix, trailing] = match; + if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id)) return segment; + const hasInlineLabel = + suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{'); + + if (hasInlineLabel) return `${leading}${aliasFor(id)}${suffix}${trailing}`; + + return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`; +} + +function escapeMermaidLabel(label: string): string { + return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function isDirectiveLine(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed === '' || + trimmed.startsWith('%%') || + trimmed.startsWith('graph ') || + trimmed.startsWith('flowchart ') || + trimmed.startsWith('sequenceDiagram') || + trimmed.startsWith('classDiagram') || + trimmed.startsWith('stateDiagram') || + trimmed.startsWith('erDiagram') || + trimmed.startsWith('journey') || + trimmed.startsWith('gantt') || + trimmed.startsWith('pie ') || + trimmed.startsWith('mindmap') || + trimmed.startsWith('timeline') || + trimmed.startsWith('subgraph ') || + trimmed === 'end' + ); +} diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 8037511bf..99e52c330 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -36,6 +36,7 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); // still works), `afterAll` rms the parent tmpdir. let MINI_REPO: string; let tmpParent: string; +let suiteGitnexusHome: string; // Absolute file:// URL to tsx loader — needed when spawning CLI with cwd // outside the project tree (bare 'tsx' specifier won't resolve there). @@ -49,6 +50,7 @@ beforeAll(() => { // Copy the fixture into an isolated tmpdir named `mini-repo` so that the // `--repo mini-repo` CLI arg (which matches by basename) still works. tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-')); + suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-')); MINI_REPO = path.join(tmpParent, 'mini-repo'); fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); @@ -75,21 +77,30 @@ afterAll(() => { if (tmpParent) { fs.rmSync(tmpParent, { recursive: true, force: true }); } + if (suiteGitnexusHome) { + fs.rmSync(suiteGitnexusHome, { recursive: true, force: true }); + } }); +function cliEnv(extraEnv: Record = {}) { + return { + ...process.env, + GITNEXUS_HOME: suiteGitnexusHome, + // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it + // and skips the re-exec. The re-exec drops the tsx loader (--import tsx + // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + ...extraEnv, + }; +} + function runCli(command: string, cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it - // and skips the re-exec. The re-exec drops the tsx loader (--import tsx - // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -103,10 +114,7 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -126,11 +134,7 @@ function runCliWithEnv( encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - ...extraEnv, - }, + env: cliEnv(extraEnv), }); } @@ -919,10 +923,7 @@ describe('CLI end-to-end', () => { encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -1042,10 +1043,7 @@ describe('CLI end-to-end', () => { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); if (result.status === null) return; @@ -1159,10 +1157,7 @@ describe('CLI end-to-end', () => { { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); @@ -1212,10 +1207,7 @@ describe('CLI end-to-end', () => { { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); diff --git a/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts new file mode 100644 index 000000000..e8c74c8cb --- /dev/null +++ b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { + sanitizeMermaidDiagram, + sanitizeMermaidMarkdown, +} from '../../src/core/wiki/mermaid-sanitizer.js'; + +describe('sanitizeMermaidMarkdown', () => { + it('replaces literal newline escapes inside rectangle and diamond labels', () => { + const markdown = [ + '```mermaid', + 'flowchart TD', + ' A[HTTP request\\nwith ID param] --> B{Preceding tei:zone\\nwith @start=#pid?}', + '```', + ].join('\n'); + + const sanitized = sanitizeMermaidMarkdown(markdown); + + expect(sanitized).toContain('A[HTTP request
with ID param]'); + expect(sanitized).toContain('B{Preceding tei:zone
with @start=#pid?}'); + expect(sanitized).not.toContain('\\n'); + }); + + it('quotes unsafe edge labels without changing safe labels', () => { + const diagram = [ + 'graph LR', + ' Script -->|doc()| eXist[(eXist-db XML)]', + ' Client -->|HTTP params| Script', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('Script -->|"doc()"| eXist[(eXist-db XML)]'); + expect(sanitized).toContain('Client -->|HTTP params| Script'); + }); + + it('escapes backslashes and quotes in quoted edge labels', () => { + const diagram = ['graph LR', ' Script -->|doc("C:\\\\tmp")| Target'].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('Script -->|"doc(\\"C:\\\\\\\\tmp\\")"| Target'); + }); + + it('aliases bare node IDs that contain dots and keeps display labels', () => { + const diagram = [ + 'graph LR', + ' Client -->|xmlurl + xslurl| xslt-conversion.xq', + ' xslt-conversion.xq -->|stream-transform| lbpwebjs-main.xsl', + ' lbpwebjs-main.xsl -->|fetches| TEI-XML[(TEI XML in eXist)]', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain( + 'Client -->|xmlurl + xslurl| xslt-conversion_xq["xslt-conversion.xq"]', + ); + expect(sanitized).toContain( + 'xslt-conversion_xq["xslt-conversion.xq"] -->|stream-transform| lbpwebjs-main_xsl["lbpwebjs-main.xsl"]', + ); + expect(sanitized).toContain( + 'lbpwebjs-main_xsl["lbpwebjs-main.xsl"] -->|fetches| TEI-XML[(TEI XML in eXist)]', + ); + }); + + it('aliases unsafe node IDs while preserving existing inline labels', () => { + const diagram = [ + 'graph LR', + ' file.name.ts[(eXist-db XML)] --> target.node["Target node"]', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('file_name_ts[(eXist-db XML)] --> target_node["Target node"]'); + }); + + it('only rewrites fenced Mermaid blocks in markdown', () => { + const markdown = [ + 'Regular text with doc() and file.name.ts.', + '', + '```ts', + 'const label = "A\\nB";', + '```', + '', + '```mermaid', + 'flowchart LR', + ' A -->|doc()| file.name.ts', + '```', + ].join('\n'); + + const sanitized = sanitizeMermaidMarkdown(markdown); + + expect(sanitized).toContain('Regular text with doc() and file.name.ts.'); + expect(sanitized).toContain('const label = "A\\nB";'); + expect(sanitized).toContain('A -->|"doc()"| file_name_ts["file.name.ts"]'); + }); +});