diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index 4f654f4cd..9b078f6ea 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -212,11 +212,23 @@ export function parseRoomShare(text) { if (at < 0) return undefined const start = raw.indexOf('{', at) if (start < 0) return undefined + // A chat line's text is a JSON string inside the object, so a brace in what someone typed is a + // character, not structure: the scan tracks strings and escapes, or one `{` would end the object + // early and drop the whole envelope (#855 reads the same tail this way). let depth = 0 let end = -1 + let inString = false + let escaped = false for (let i = start; i < raw.length; i += 1) { const ch = raw[i] - if (ch === '{') depth += 1 + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') depth += 1 else if (ch === '}') { depth -= 1 if (depth === 0) { end = i; break } diff --git a/core/test/room-share-braces.test.js b/core/test/room-share-braces.test.js new file mode 100644 index 000000000..2afa87532 --- /dev/null +++ b/core/test/room-share-braces.test.js @@ -0,0 +1,39 @@ +// A room-share envelope is one JSON object after a marker line, and a chat line's text is a JSON +// string inside it: `{` and `}` in that text are ordinary characters, not structure. The scan that +// finds the object's end must therefore know when it is inside a string (and inside an escape), the +// same way the social plugin's own tail reader does (#855) — otherwise one brace in what someone +// typed drops the whole envelope. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' + +const line = (text) => parseRoomShare(encodeRoomShare({ op: 'line', roomId: 'r1', seq: 7, who: 'Alice', text })) + +test('a line whose text opens a brace is read back, not dropped', () => { + const parsed = line('if (x) { y()') + assert.equal(parsed?.op, 'line') + assert.equal(parsed?.roomId, 'r1') + assert.equal(parsed?.text, 'if (x) { y()') +}) + +test('a line whose text closes a brace is read back, not dropped', () => { + assert.equal(line('so the closing brace is }')?.text, 'so the closing brace is }') +}) + +test('a brace right after an escaped quote is still inside the string', () => { + assert.equal(line('say "{" now')?.text, 'say "{" now') +}) + +test('a copied history line keeps braces too', () => { + const wire = encodeRoomShare({ op: 'join', roomId: 'abc123', seq: 3, who: 'Alice', history: [{ who: 'Bob', text: 'map { a: 1 }' }] }) + assert.equal(parseRoomShare(wire)?.history?.[0]?.text, 'map { a: 1 }') +}) + +test('plain text and balanced braces keep their current reading', () => { + assert.equal(line('plain text')?.text, 'plain text') + assert.equal(line('a {b} c')?.text, 'a {b} c') +}) + +test('a truncated envelope is still no envelope', () => { + assert.equal(parseRoomShare('FLYBOT-ROOM-SHARE\n{"v":1,"op":"line","roomId":"r1"'), undefined) +}) diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index 4f654f4cd..9b078f6ea 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -212,11 +212,23 @@ export function parseRoomShare(text) { if (at < 0) return undefined const start = raw.indexOf('{', at) if (start < 0) return undefined + // A chat line's text is a JSON string inside the object, so a brace in what someone typed is a + // character, not structure: the scan tracks strings and escapes, or one `{` would end the object + // early and drop the whole envelope (#855 reads the same tail this way). let depth = 0 let end = -1 + let inString = false + let escaped = false for (let i = start; i < raw.length; i += 1) { const ch = raw[i] - if (ch === '{') depth += 1 + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') depth += 1 else if (ch === '}') { depth -= 1 if (depth === 0) { end = i; break } diff --git a/patches/ds-bot/files/test/room-share-braces.test.js b/patches/ds-bot/files/test/room-share-braces.test.js new file mode 100644 index 000000000..2afa87532 --- /dev/null +++ b/patches/ds-bot/files/test/room-share-braces.test.js @@ -0,0 +1,39 @@ +// A room-share envelope is one JSON object after a marker line, and a chat line's text is a JSON +// string inside it: `{` and `}` in that text are ordinary characters, not structure. The scan that +// finds the object's end must therefore know when it is inside a string (and inside an escape), the +// same way the social plugin's own tail reader does (#855) — otherwise one brace in what someone +// typed drops the whole envelope. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' + +const line = (text) => parseRoomShare(encodeRoomShare({ op: 'line', roomId: 'r1', seq: 7, who: 'Alice', text })) + +test('a line whose text opens a brace is read back, not dropped', () => { + const parsed = line('if (x) { y()') + assert.equal(parsed?.op, 'line') + assert.equal(parsed?.roomId, 'r1') + assert.equal(parsed?.text, 'if (x) { y()') +}) + +test('a line whose text closes a brace is read back, not dropped', () => { + assert.equal(line('so the closing brace is }')?.text, 'so the closing brace is }') +}) + +test('a brace right after an escaped quote is still inside the string', () => { + assert.equal(line('say "{" now')?.text, 'say "{" now') +}) + +test('a copied history line keeps braces too', () => { + const wire = encodeRoomShare({ op: 'join', roomId: 'abc123', seq: 3, who: 'Alice', history: [{ who: 'Bob', text: 'map { a: 1 }' }] }) + assert.equal(parseRoomShare(wire)?.history?.[0]?.text, 'map { a: 1 }') +}) + +test('plain text and balanced braces keep their current reading', () => { + assert.equal(line('plain text')?.text, 'plain text') + assert.equal(line('a {b} c')?.text, 'a {b} c') +}) + +test('a truncated envelope is still no envelope', () => { + assert.equal(parseRoomShare('FLYBOT-ROOM-SHARE\n{"v":1,"op":"line","roomId":"r1"'), undefined) +})