From 35e281950ea33c3f05304d63fe0c63e0ec8af3eb Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 7 Nov 2026 18:44:34 +0800 Subject: [PATCH] Cut peer-supplied face-room text on code points, not UTF-16 units A cap that landed between the halves of a surrogate pair stored a lone high surrogate for a peer's face-room name, role label, platform line, and the two 200-unit failure details, so a remote member could make a label render as a replacement character. All five cuts now share one code-point bound. Fixes #2438 Docs-not-needed: a cut helper inside an existing sanitiser; no subsystem behaviour changes --- changelog.d/2438.fixed.md | 1 + plugins/social/src/face-rooms.js | 28 +++++---- plugins/social/test/face-text-bounds.test.js | 60 ++++++++++++++++++++ 3 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 changelog.d/2438.fixed.md create mode 100644 plugins/social/test/face-text-bounds.test.js diff --git a/changelog.d/2438.fixed.md b/changelog.d/2438.fixed.md new file mode 100644 index 000000000..c128f5f68 --- /dev/null +++ b/changelog.d/2438.fixed.md @@ -0,0 +1 @@ +Face-to-face room names, role labels, platform lines and surface Bot Social's own failure text now cap on character boundaries instead of UTF-16 units, so an emoji at the cut is no longer split into a half character and shown as a replacement character ([#2438](https://github.com/ThinkFlowLab/FlyBot/issues/2438)). diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index c65479162..ca1d094ab 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -21,6 +21,15 @@ const CALL_TIMEOUT_MS = 30_000 const NAME_MAX = 60 const SEEN_KEPT = 200 +// Peer text is cut on code points, never on UTF-16 units: a cut between the halves of a surrogate +// pair stores a lone surrogate, which every encoder reads as U+FFFD (#2438). The plugin ships as its +// own package and may not reach into core's shared/text-boundary.js (#155), so the rule is repeated +// here the way social.js's `cutCodePoints` already repeats it. +const cutCodePoints = (value, max) => { + const text = String(value ?? '').toWellFormed() + return text.length <= max ? text : Array.from(text).slice(0, max).join('') +} + export class FaceRoomError extends Error { constructor(message, code, extra = {}) { super(message) @@ -74,12 +83,11 @@ export const localInviteHandle = value => parseInviteLink(value)?.handle ?? '' // brackets, markup or @, capped. Used for every peer-controlled name and for platform // system copy that embeds those names. export function cleanFaceName(value, fallback = 'a remote Bot') { - const text = stripFaceMarkers(String(value ?? '')) + const text = cutCodePoints(stripFaceMarkers(String(value ?? '')) .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') .replace(/[[\]"<>`@]/g, '') .replace(/\s+/g, ' ') - .trim() - .slice(0, NAME_MAX) + .trim(), NAME_MAX) return text || fallback } @@ -196,7 +204,7 @@ export function mapRegisterError(error, bot) { if (code === 'http_401' || code === 'http_403' || code === 'unauthorized' || /401|403|unauthori[sz]ed|token/i.test(code + msg)) { return new FaceRoomError(FRIENDLY.unauthorized, 'unauthorized', { status: 401 }) } - const raw = msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200) + const raw = cutCodePoints(msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim(), 200) const detail = raw && !/^[a-z_]+$/.test(raw) ? `: ${raw}` : '. Try again.' return new FaceRoomError(`Could not register ${name} on Bot Social${detail}`, code || 'register_failed') } @@ -221,7 +229,7 @@ export function mapFaceError(data, status) { ? `Too many failed attempts. Try again in ${formatRetryAfter(retryAfter)}.` : 'Too many failed attempts. Try again later.' } else if (!message) { - const raw = String(payload.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) + const raw = cutCodePoints(String(payload.message ?? '').replace(/\s+/g, ' ').trim(), 200) message = raw && !/^[a-z_]+$/.test(raw) ? raw : 'Could not join that room. Try another number.' if (status === 401) message = FRIENDLY.unauthorized else if (status === 429) message = retryAfter != null @@ -500,12 +508,11 @@ export function viewFaceMember(member) { export const FACE_ROLE_MAX = 80 export function cleanFaceRole(value) { if (typeof value !== 'string') return '' - return stripFaceMarkers(value) + return cutCodePoints(stripFaceMarkers(value) .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') .replace(/[[\]"<>`@]/g, '') .replace(/\s+/g, ' ') - .trim() - .slice(0, FACE_ROLE_MAX) + .trim(), FACE_ROLE_MAX) } // The room lead every home follows (#476). Bot Social sends the creator's pick, else its default @@ -610,12 +617,11 @@ function sanitizePlatformText(text, type, member, reason) { if (type === 'member.left' && member) return platformLeaveText(member) if (type === 'room.started') return platformStartedText(reason) if (type === 'room.lead_changed') return platformLeadText(member, reason) - const cleaned = String(text ?? '') + const cleaned = cutCodePoints(String(text ?? '') .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') .replace(/[[\]"<>`@]/g, '') .replace(/\s+/g, ' ') - .trim() - .slice(0, 200) + .trim(), 200) return cleaned } diff --git a/plugins/social/test/face-text-bounds.test.js b/plugins/social/test/face-text-bounds.test.js new file mode 100644 index 000000000..c3c24efd6 --- /dev/null +++ b/plugins/social/test/face-text-bounds.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { cleanFaceName, cleanFaceRole, mapFaceError, mapRegisterError, viewFaceEvent } from '../src/face-rooms.js' + +// Peer-supplied face-room text is capped (#2438). A cap that lands between the halves of a surrogate +// pair stores a lone high surrogate, which every encoder reads as U+FFFD, so each cut must land +// on a code point and each cap must count code points. `\p{Surrogate}` with the `u` flag matches a +// lone surrogate only: a valid pair is one code point outside that range. +const ROCKET = '\u{1F680}' +const REPLACEMENT = String.fromCharCode(0xfffd) +const shapes = value => { + assert.equal(typeof value, 'string') + assert.ok(value.isWellFormed(), `not well formed: ${JSON.stringify(value)}`) + assert.ok(!/\p{Surrogate}/u.test(value), `lone surrogate: ${JSON.stringify(value)}`) + assert.ok(!value.includes(REPLACEMENT), `replacement character: ${JSON.stringify(value)}`) + return value +} + +test('a face-room name cut lands on a code point boundary, not inside an emoji', () => { + const stored = shapes(cleanFaceName(`${'a'.repeat(59)}${ROCKET}rest`)) + assert.equal(stored, `${'a'.repeat(59)}${ROCKET}`) +}) + +test('the name cap counts code points, so whole emoji are never cut in half', () => { + const stored = shapes(cleanFaceName(ROCKET.repeat(70))) + assert.equal(Array.from(stored).length, 60) +}) + +test('a face-room role cut lands on a code point boundary, not inside an emoji', () => { + const stored = shapes(cleanFaceRole(`${'a'.repeat(79)}${ROCKET}rest`)) + assert.equal(stored, `${'a'.repeat(79)}${ROCKET}`) +}) + +test('the role cap counts code points, so whole emoji are never cut in half', () => { + const stored = shapes(cleanFaceRole(ROCKET.repeat(90))) + assert.equal(Array.from(stored).length, 80) +}) + +test('a platform line cut lands on a code point boundary, not inside an emoji', () => { + const viewed = viewFaceEvent({ type: 'room.pinned', text: `${'a'.repeat(199)}${ROCKET}rest` }) + const stored = shapes(viewed.text) + assert.equal(stored, `${'a'.repeat(199)}${ROCKET}`) +}) + +test('the platform line cap counts code points, so whole emoji are never cut in half', () => { + const viewed = viewFaceEvent({ type: 'room.pinned', text: ROCKET.repeat(250) }) + assert.equal(Array.from(shapes(viewed.text)).length, 200) +}) + +test('a failed registration detail lands on a code point boundary, not inside an emoji', () => { + const error = mapRegisterError({ message: `${'a'.repeat(199)}${ROCKET}rest` }, { name: 'Bolt' }) + const stored = shapes(error.message) + assert.ok(stored.endsWith(`${'a'.repeat(199)}${ROCKET}`), stored.slice(-8)) +}) + +test('an unmapped join error message lands on a code point boundary, not inside an emoji', () => { + const error = mapFaceError({ error: { code: 'odd_room_failure', message: `${'a'.repeat(199)}${ROCKET}rest` } }, 400) + const stored = shapes(error.message) + assert.equal(stored, `${'a'.repeat(199)}${ROCKET}`) +})