From 901296469735554a448a74dbcf004aa8ddd659c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BB=BA=E5=86=9B?= Date: Tue, 6 Oct 2026 14:18:53 +0800 Subject: [PATCH] fix: keep surrogate pairs whole when truncating file names sanitizeFileName joined code points and then sliced the UTF-16 string, so a non-BMP character straddling the 180-character boundary was split into a lone surrogate that corrupted the stored display name. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/files.ts | 5 +++-- tests/resource-ids.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/files.ts b/apps/server/src/files.ts index 239985ed9..1bdecefe3 100644 --- a/apps/server/src/files.ts +++ b/apps/server/src/files.ts @@ -12,10 +12,11 @@ const FILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0- export function sanitizeFileName(name: string): string { const base = name.split(/[\\/]/).at(-1) ?? "document.pdf"; + // Slice code points, not UTF-16 units, so a surrogate pair is never split in half. const cleaned = Array.from(base) .filter((character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127) - .join("") - .slice(0, 180); + .slice(0, 180) + .join(""); return cleaned || "document.pdf"; } diff --git a/tests/resource-ids.test.ts b/tests/resource-ids.test.ts index 0dd225517..84392cda8 100644 --- a/tests/resource-ids.test.ts +++ b/tests/resource-ids.test.ts @@ -53,6 +53,11 @@ test("file names never collapse to an empty display name", () => { const long = sanitizeFileName(`${"a".repeat(300)}.pdf`); assert.ok(long.length <= 180); assert.ok(long.length > 0); + // The 180-character bound slices whole code points, never half a surrogate pair. + const emoji = sanitizeFileName(`${"x".repeat(179)}😀.pdf`); + assert.equal(emoji, `${"x".repeat(179)}😀`); + assert.equal([...emoji].length, 180); + assert.equal([...sanitizeFileName(`${"x".repeat(180)}😀.pdf`)].length, 180); }); test("file lookups reject malformed IDs instead of probing storage", async (t) => {