From 81e448cf0597502d5a0cf63cf5a96b5d37eee471 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 7 Jul 2026 18:53:11 +0700 Subject: [PATCH] fix(markdown-editor): normalize CRLF in escapeTablePipes so tables keep cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-lex pipe protection split on '\n', leaving a trailing '\r' on every line. The '$'-anchored TABLE_DELIMITER_LINE then failed to match '| --- |\r', so the whole module no-op'd on CRLF input and marked dropped cells whose code spans / templates / URLs held a pipe — silently losing data on the first load of Windows- or API-authored content. Normalize '\r\n' and lone '\r' to '\n' up front (marked re-normalizes the returned string anyway). Original bytes are preserved when nothing is escaped. Co-Authored-By: Claude Opus 4.8 --- .../markdown-editor-table-pipes.test.ts | 21 +++++++++++++++++++ .../markdown-editor-table-pipes.ts | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts index 6ffe5925..438fc40b 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.test.ts @@ -143,6 +143,27 @@ describe('table cell with a pipe inside a URL — content survives load and conv }); }); +describe('CRLF line endings — tables still protected', () => { + it('escapes a code-span pipe in a CRLF table row', () => { + expect(escapeTablePipes('| a | b |\r\n| --- | --- |\r\n| `x | y` | z |\r\n')).toBe( + '| a | b |\n| --- | --- |\n| `x \\| y` | z |\n', + ); + }); + + it('keeps the trailing cell of a CRLF table on round-trip', () => { + const out = roundTrip('| a | b |\r\n| --- | --- |\r\n| `x | y` | z |\r\n'); + + expect(out).toContain('z'); + expect(out).toContain('`x \\| y`'); + }); + + it('leaves CRLF bytes untouched when there is no table to escape', () => { + const doc = 'line one\r\nline `a | b` two\r\n'; + + expect(escapeTablePipes(doc)).toBe(doc); + }); +}); + describe('TABLE_DELIMITER_LINE is linear (ReDoS guard)', () => { it('scans a crafted delimiter-looking line with a long trailing space run in linear time', () => { // A `|`-line followed by "dashes + many spaces + non-matching tail" was O(n²) on the old regex diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts index 39f2b7b7..f28c6709 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-pipes.ts @@ -137,7 +137,11 @@ export const escapeTablePipes = (markdown: string): string => { return markdown; } - const lines = markdown.split('\n'); + // marked normalizes CRLF itself, but this pre-pass runs BEFORE it: a trailing `\r` left by split('\n') + // defeats the `$`-anchored TABLE_DELIMITER_LINE, so a CRLF document's tables would go unprotected here and + // lose cells. Normalize first; the return below keeps the original bytes when nothing was escaped. + const source = markdown.includes('\r') ? markdown.replace(/\r\n?/g, '\n') : markdown; + const lines = source.split('\n'); let openFence: null | string = null; let isChanged = false;