From ae3a257d94d4bc24c5203c47e7f9d0de4871696e Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:17:09 +0800 Subject: [PATCH] host: bound the joined notice and the copied line the pane draws on code point boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A room share arrives from another owner, and two host-side readers of that stranger-written text still cut UTF-16 units with a raw `slice`: - `room-membership.js:1096` `noticeOf` — the notice a join carries, `…trim().slice(0, 2000)`; a notice whose 2,000th unit is half of a surrogate pair was stored with the lone high surrogate, so every later read of the room's notice carried U+FFFD. It now goes through `cut` at the shared `NOTICE_MAX`, the bound the envelope already uses. - `room-membership.js:1190` `applyShareHistory` — the stored copy of a history line is `cut(...)` at `HISTORY_TEXT` (2,000, #1928), but the pane was handed the sender's raw `entry.text`: the guest drew a longer line than its own log kept, past the whole pair the stored bound dropped. The shown text now goes through the same `cut` at the same bound. - `fitJoin`'s notice shrink loop is untouched: each round re-measures the JSON, and an escaped half pair cannot survive it (measured 0 ill-formed entries). Both bounds are unchanged; only the unit a prefix stops before moves, the rule #1912/#1916/#1923/#1928 already apply. Two new cases in `core/test/remote-room.test.js` pin the parsed notice and the drawn line; each fails when its own site goes back to `slice`. Docs-not-needed: no owned subsystem page states a 2,000-unit bound on a joined notice or on the pane line copied from another owner; the limits are unchanged, only the unit a prefix stops on moves. --- ...33-room-share-stranger-text-bound.fixed.md | 1 + core/src/host/room-membership.js | 6 +- core/test/remote-room.test.js | 60 +++++++++++++++++++ .../ds-bot/files/src/host/room-membership.js | 6 +- patches/ds-bot/files/test/remote-room.test.js | 60 +++++++++++++++++++ 5 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1933-room-share-stranger-text-bound.fixed.md diff --git a/changelog.d/1933-room-share-stranger-text-bound.fixed.md b/changelog.d/1933-room-share-stranger-text-bound.fixed.md new file mode 100644 index 000000000..4e56bd9fd --- /dev/null +++ b/changelog.d/1933-room-share-stranger-text-bound.fixed.md @@ -0,0 +1 @@ +A joined room's notice and the pane line copied from another owner's FlyBot keep whole characters ([#1933](https://github.com/ThinkFlowLab/FlyBot/issues/1933); [#1928](https://github.com/ThinkFlowLab/FlyBot/issues/1928) covered the envelopes, and these are the two host-side readers it left on a raw code-unit cut): the guest bounds the notice it stores at its own 2,000 units and the history line it draws at the same 2,000-unit bound it already stores, both by backing up to a code point boundary, so a text whose 2,000th unit is half of a surrogate pair no longer leaves that half in the notice or in the pane — it reads as U+FFFD no more. The pane used to be shown the sender's raw `entry.text`, longer than the line the guest's own log kept; it now draws exactly the bounded prefix the log holds. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index f452d6a71..1175b10a1 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -36,7 +36,7 @@ import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, remoteCallName, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { HISTORY_MAX, HISTORY_TEXT, ROOM_SHARE_TEXT_MAX, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' +import { HISTORY_MAX, HISTORY_TEXT, NOTICE_MAX, ROOM_SHARE_TEXT_MAX, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cut } from '../shared/text-boundary.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js' @@ -1093,7 +1093,7 @@ export function install(rt) { // from that room's own inviter; a present empty value clears the notice, an absent field leaves it. function noticeOf(data, room, event) { if (data?.op !== 'join' || typeof data.notice !== 'string') return undefined - const text = data.notice.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, 2000) + const text = cut(data.notice.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(), NOTICE_MAX).text const bound = room?.sharedFrom ?? {} const same = (a, b) => { const left = String(a ?? '').trim().toLowerCase() @@ -1189,7 +1189,7 @@ export function install(rt) { })) await showShareLine(room, { who, - text: entry.text, + text: cut(String(entry.text), HISTORY_TEXT).text, remoteSeq: entry.seq, via: `${data.roomId}:${fromHandle}`, senderSessionId: handle || fromHandle, diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 9c14a2ae4..bb839bc3e 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -974,6 +974,66 @@ test('a guest bounds a copied history line on a code point boundary', async (t) assert.equal(copied.text.includes('\uFFFD'), false, 'the guest shows no replacement character') }) +// The join's notice is stranger-written text too, and the guest keeps it without trusting the sender's +// own clip: a notice whose 2,000th unit is half of a surrogate pair used to be stored with the lone +// high surrogate, so every read of the notice carried U+FFFD. +test('a joined notice is kept on a code point boundary', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const notice = `${'a'.repeat(1999)}\u{1F600}${'b'.repeat(400)}` + const join = `FLYBOT-ROOM-SHARE\n${JSON.stringify({ + v: 1, op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 1, notice, + })}` + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + const kept = rt.state.rooms[first.room.id].notice + assert.equal(typeof kept, 'string', `the notice rode the join: ${JSON.stringify(kept)}`) + assert.equal(kept.isWellFormed(), true, `not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.equal(kept.includes('\uFFFD'), false, 'reading the notice back finds no replacement character') + assert.equal(kept, 'a'.repeat(1999), 'the bound retracts the whole pair instead of half of it') + assert.ok(kept.length <= 2000, `${kept.length} code units, over the notice bound`) + assert.equal(Buffer.from(kept, 'utf8').equals(Buffer.from(notice, 'utf8').subarray(0, Buffer.byteLength(kept))), true, 'the notice is a UTF-8 byte prefix of what the host sent') +}) + +// A copied history line is stored on a code point boundary, but the pane was shown the raw +// `entry.text` the sender wrote: the guest read a longer line than its own log kept, past the whole +// pair the stored bound had retracted. +test('a copied history line is shown as the same bounded prefix it is stored as', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const shown = [] + rt.showSharedLine = async (room, spec) => { shown.push({ roomId: room.id, ...spec }) } + const long = `${'a'.repeat(1999)}\u{1F600}${'b'.repeat(400)}` + const join = `FLYBOT-ROOM-SHARE\n${JSON.stringify({ + v: 1, op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 1, history: [{ who: 'Ann', text: long, seq: 7 }], + })}` + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + const copied = rt.roomHistory(first.room.id, actorB).find(entry => entry.who === 'Ann') + assert.ok(copied, `the guest copies the line: ${JSON.stringify(rt.roomHistory(first.room.id, actorB).map(entry => entry.who))}`) + const drawn = shown.find(item => item.roomId === first.room.id && item.who === 'Ann') + assert.ok(drawn, `the guest draws the copied line: ${JSON.stringify(shown.map(item => item.roomId))}`) + assert.equal(drawn.text, 'a'.repeat(1999), 'the shown line retracts the whole pair instead of half of it') + assert.equal(copied.text.includes(drawn.text), true, 'the pane shows the line the log kept, not a longer one') + assert.equal(drawn.text.includes('\u{1F600}'), false, 'the pair the stored bound retracted is not drawn past it') + assert.equal(drawn.text.isWellFormed(), true, `not well formed: ${JSON.stringify(drawn.text.slice(-2))}`) + assert.equal(drawn.text.includes('\uFFFD'), false, 'the pane carries no replacement character') + assert.ok(drawn.text.length <= HISTORY_TEXT, `${drawn.text.length} code units, over ${HISTORY_TEXT}`) + assert.equal(Buffer.from(drawn.text, 'utf8').equals(Buffer.from(long, 'utf8').subarray(0, Buffer.byteLength(drawn.text))), true, 'the shown line is a UTF-8 byte prefix of what the host typed') +}) + test('applyInboundRoomShare projects a join with no chat line, then a leave hides it', async (t) => { const { rt } = host(t, TEAM) await rt.load() diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index f452d6a71..1175b10a1 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -36,7 +36,7 @@ import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, remoteCallName, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { HISTORY_MAX, HISTORY_TEXT, ROOM_SHARE_TEXT_MAX, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' +import { HISTORY_MAX, HISTORY_TEXT, NOTICE_MAX, ROOM_SHARE_TEXT_MAX, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cut } from '../shared/text-boundary.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js' @@ -1093,7 +1093,7 @@ export function install(rt) { // from that room's own inviter; a present empty value clears the notice, an absent field leaves it. function noticeOf(data, room, event) { if (data?.op !== 'join' || typeof data.notice !== 'string') return undefined - const text = data.notice.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, 2000) + const text = cut(data.notice.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(), NOTICE_MAX).text const bound = room?.sharedFrom ?? {} const same = (a, b) => { const left = String(a ?? '').trim().toLowerCase() @@ -1189,7 +1189,7 @@ export function install(rt) { })) await showShareLine(room, { who, - text: entry.text, + text: cut(String(entry.text), HISTORY_TEXT).text, remoteSeq: entry.seq, via: `${data.roomId}:${fromHandle}`, senderSessionId: handle || fromHandle, diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 9c14a2ae4..bb839bc3e 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -974,6 +974,66 @@ test('a guest bounds a copied history line on a code point boundary', async (t) assert.equal(copied.text.includes('\uFFFD'), false, 'the guest shows no replacement character') }) +// The join's notice is stranger-written text too, and the guest keeps it without trusting the sender's +// own clip: a notice whose 2,000th unit is half of a surrogate pair used to be stored with the lone +// high surrogate, so every read of the notice carried U+FFFD. +test('a joined notice is kept on a code point boundary', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const notice = `${'a'.repeat(1999)}\u{1F600}${'b'.repeat(400)}` + const join = `FLYBOT-ROOM-SHARE\n${JSON.stringify({ + v: 1, op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 1, notice, + })}` + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + const kept = rt.state.rooms[first.room.id].notice + assert.equal(typeof kept, 'string', `the notice rode the join: ${JSON.stringify(kept)}`) + assert.equal(kept.isWellFormed(), true, `not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.equal(kept.includes('\uFFFD'), false, 'reading the notice back finds no replacement character') + assert.equal(kept, 'a'.repeat(1999), 'the bound retracts the whole pair instead of half of it') + assert.ok(kept.length <= 2000, `${kept.length} code units, over the notice bound`) + assert.equal(Buffer.from(kept, 'utf8').equals(Buffer.from(notice, 'utf8').subarray(0, Buffer.byteLength(kept))), true, 'the notice is a UTF-8 byte prefix of what the host sent') +}) + +// A copied history line is stored on a code point boundary, but the pane was shown the raw +// `entry.text` the sender wrote: the guest read a longer line than its own log kept, past the whole +// pair the stored bound had retracted. +test('a copied history line is shown as the same bounded prefix it is stored as', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const shown = [] + rt.showSharedLine = async (room, spec) => { shown.push({ roomId: room.id, ...spec }) } + const long = `${'a'.repeat(1999)}\u{1F600}${'b'.repeat(400)}` + const join = `FLYBOT-ROOM-SHARE\n${JSON.stringify({ + v: 1, op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 1, history: [{ who: 'Ann', text: long, seq: 7 }], + })}` + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + const copied = rt.roomHistory(first.room.id, actorB).find(entry => entry.who === 'Ann') + assert.ok(copied, `the guest copies the line: ${JSON.stringify(rt.roomHistory(first.room.id, actorB).map(entry => entry.who))}`) + const drawn = shown.find(item => item.roomId === first.room.id && item.who === 'Ann') + assert.ok(drawn, `the guest draws the copied line: ${JSON.stringify(shown.map(item => item.roomId))}`) + assert.equal(drawn.text, 'a'.repeat(1999), 'the shown line retracts the whole pair instead of half of it') + assert.equal(copied.text.includes(drawn.text), true, 'the pane shows the line the log kept, not a longer one') + assert.equal(drawn.text.includes('\u{1F600}'), false, 'the pair the stored bound retracted is not drawn past it') + assert.equal(drawn.text.isWellFormed(), true, `not well formed: ${JSON.stringify(drawn.text.slice(-2))}`) + assert.equal(drawn.text.includes('\uFFFD'), false, 'the pane carries no replacement character') + assert.ok(drawn.text.length <= HISTORY_TEXT, `${drawn.text.length} code units, over ${HISTORY_TEXT}`) + assert.equal(Buffer.from(drawn.text, 'utf8').equals(Buffer.from(long, 'utf8').subarray(0, Buffer.byteLength(drawn.text))), true, 'the shown line is a UTF-8 byte prefix of what the host typed') +}) + test('applyInboundRoomShare projects a join with no chat line, then a leave hides it', async (t) => { const { rt } = host(t, TEAM) await rt.load()