Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1933-room-share-stranger-text-bound.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions patches/ds-bot/files/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading