From 331b9cac78ca0b4eb53af6aba4720f2d1a9cda6e Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:45:10 +0800 Subject: [PATCH] social: cap face-room names, roles and platform lines on code points (#2438) Fixes #2438 Written-by: Delivery-2 cleanFaceName, cleanFaceRole, sanitizePlatformText and the two join-error details clipped with a bare String#slice, so a cap that landed between the halves of a surrogate pair stored a lone high surrogate: a peer's display name of 59 characters plus an emoji came back as 60 units ending in \ud83d, drawn as a replacement character in the member list and carried into the untrusted-message fence. The caps also counted UTF-16 units, so an emoji-heavy name reached half the room of an ASCII one. One local cut() helper now serves the five sites; no cap value, wording, wire shape or endpoint changes. Docs-not-needed: no ownership, contract or module boundary changed; five text caps in face-rooms.js now cut on code points. --- ...2438-social-face-text-code-points.fixed.md | 1 + plugins/social/src/face-rooms.js | 23 ++++++++-------- plugins/social/test/face-text-bounds.test.js | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 changelog.d/2438-social-face-text-code-points.fixed.md create mode 100644 plugins/social/test/face-text-bounds.test.js diff --git a/changelog.d/2438-social-face-text-code-points.fixed.md b/changelog.d/2438-social-face-text-code-points.fixed.md new file mode 100644 index 000000000..454c5d56a --- /dev/null +++ b/changelog.d/2438-social-face-text-code-points.fixed.md @@ -0,0 +1 @@ +A face-to-face room now caps the names, roles, platform lines and join errors it takes from the other home on code points, so a member whose display name ends just past the cap shows a whole character instead of `๏ฟฝ`, and a 60- or 80-character label counts what a person counts ([#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..a34d4080c 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -20,6 +20,9 @@ export const FACE_TTL_MS = 10 * 60 * 1000 const CALL_TIMEOUT_MS = 30_000 const NAME_MAX = 60 const SEEN_KEPT = 200 +// A cap on peer-supplied text cuts on code points, never on UTF-16 units: a unit offset can split +// a surrogate pair and leave a lone surrogate, which renders as a replacement character (#2431). +const cut = (text, max) => (!Number.isFinite(max) || text.length <= max ? text : Array.from(text).slice(0, max).join('')) export class FaceRoomError extends Error { constructor(message, code, extra = {}) { @@ -74,12 +77,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 = cut(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 +198,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 = cut(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 +223,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 = cut(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 +502,11 @@ export function viewFaceMember(member) { export const FACE_ROLE_MAX = 80 export function cleanFaceRole(value) { if (typeof value !== 'string') return '' - return stripFaceMarkers(value) + return cut(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,13 +611,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 ?? '') + return cut(String(text ?? '') .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') .replace(/[[\]"<>`@]/g, '') .replace(/\s+/g, ' ') - .trim() - .slice(0, 200) - return cleaned + .trim(), 200) } // `attested`: the server stamps typed_by_owner (Bot Social features), so a line is a person's only when 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..55786b414 --- /dev/null +++ b/plugins/social/test/face-text-bounds.test.js @@ -0,0 +1,27 @@ +// A peer-controlled face-room name and role are capped before they reach a room, a transcript or +// an `` fence, and the cap must land on a code point boundary: a cut between +// the halves of a surrogate pair stores a lone surrogate, which every encoder reads as U+FFFD and +// every reader draws as a replacement character (#2438). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { cleanFaceName, cleanFaceRole } from '../src/face-rooms.js' + +test('a face-room name bound lands on a code point boundary, not inside an emoji', () => { + const name = cleanFaceName(`${'x'.repeat(59)}๐Ÿš€rest`) + assert.equal(name.isWellFormed(), true, 'the capped name keeps half a surrogate pair') + assert.deepEqual([...name].length, 60, 'the name keeps its 60-character cap') + assert.equal([...name].at(-1), '๐Ÿš€', 'the last whole character is the emoji, not half of it') +}) + +test('the name cap counts code points, so whole emoji are never cut in half', () => { + const kept = cleanFaceName('๐Ÿš€'.repeat(60)) + assert.deepEqual([...kept].length, 60, 'all 60 code points survive the cap') + assert.equal(kept.length, 120, 'the cap is counted in code points, so no emoji is cut in half') +}) + +test('a face-room role bound lands on a code point boundary, not inside an emoji', () => { + const role = cleanFaceRole(`${'y'.repeat(79)}๐Ÿš€rest`) + assert.equal(role.isWellFormed(), true, 'the capped role keeps half a surrogate pair') + assert.deepEqual([...role].length, 80, 'the role keeps its 80-character cap') + assert.equal([...role].at(-1), '๐Ÿš€', 'the last whole character is the emoji, not half of it') +})