diff --git a/packages/server/src/server/file-upload/index.test.ts b/packages/server/src/server/file-upload/index.test.ts index 7eb97a07c3..8761529ec6 100644 --- a/packages/server/src/server/file-upload/index.test.ts +++ b/packages/server/src/server/file-upload/index.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -56,6 +56,49 @@ describe("file uploads", () => { expect(readFileSync(path, "utf8")).toBe("hello world"); }); + it("keeps the original file name for non-ASCII and punctuated names", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + for (const fileName of [ + "2026年9月绩效计划表.xlsx", + "테스트 파일 (1).xlsx", + "résumé [final] & notes, v2.pdf", + "cafe\u0301 हिंदी.txt", + ]) { + const file = await uploadNamed(uploads, fileName); + expect(file?.fileName).toBe(fileName); + expect(basename(file!.path)).toBe(fileName); + expect(readFileSync(file!.path, "utf8")).toBe("hello world"); + } + }); + + it("replaces path separators, control characters, and characters Windows rejects", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + await expect(uploadNamed(uploads, "../../etc/passwd")).resolves.toMatchObject({ + fileName: "passwd", + }); + const backslashed = await uploadNamed(uploads, "dir\\name.txt"); + expect(backslashed?.fileName).not.toContain("\\"); + expect(backslashed?.fileName).toMatch(/name\.txt$/); + await expect(uploadNamed(uploads, 'a:"c|?*.txt')).resolves.toMatchObject({ + fileName: "a_b___c___.txt", + }); + await expect(uploadNamed(uploads, "line\nbreak.txt")).resolves.toMatchObject({ + fileName: "line_break.txt", + }); + }); + + it("shortens a long non-ASCII name to the file system limit and keeps its extension", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + const file = await uploadNamed(uploads, `${"绩".repeat(100)}.xlsx`); + + expect(file?.fileName).toBe(`${"绩".repeat(83)}.xlsx`); + expect(Buffer.byteLength(file!.fileName)).toBeLessThanOrEqual(255); + expect(readFileSync(file!.path, "utf8")).toBe("hello world"); + }); + it("rejects chunks beyond the declared size and removes the partial file", async () => { const paseoHome = makePaseoHome(); const uploads = new FileUploadStore({ paseoHome }); @@ -202,6 +245,25 @@ describe("file uploads", () => { }); }); +let uploadCount = 0; + +async function uploadNamed(uploads: FileUploadStore, fileName: string) { + const requestId = `req-named-${uploadCount++}`; + uploads.beginUpload({ + type: "file.upload.request", + fileName, + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", + requestId, + }); + await uploads.receiveFrame(uploadBegins(requestId)); + await uploads.receiveFrame(uploadChunk(requestId, "hello world")); + const response = await uploads.receiveFrame(uploadEnds(requestId)); + expect(response?.payload.error).toBeNull(); + return response?.payload.file; +} + function makePaseoHome(): string { const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-"))); tempDirs.push(root); diff --git a/packages/server/src/server/file-upload/index.ts b/packages/server/src/server/file-upload/index.ts index 8b93e31f2f..979130d875 100644 --- a/packages/server/src/server/file-upload/index.ts +++ b/packages/server/src/server/file-upload/index.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename, extname, join } from "node:path"; import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; @@ -220,9 +220,26 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU }; } +// Most file systems cap a single file name at 255 bytes. +const MAX_FILE_NAME_BYTES = 255; + +// Keeps the client's file name, replacing only what cannot appear in a single +// file name on Linux, macOS, or Windows. function sanitizeFileName(value: string): string { const name = basename(value) - .replace(/[^a-zA-Z0-9._ -]/g, "_") + .replace(/[\p{Cc}\\/:*?"<>|]/gu, "_") .trim(); - return name.length > 0 && name !== "." && name !== ".." ? name : "upload"; + return fitFileNameLength(name.length > 0 && name !== "." && name !== ".." ? name : "upload"); +} + +function fitFileNameLength(name: string): string { + if (Buffer.byteLength(name) <= MAX_FILE_NAME_BYTES) return name; + const extension = extname(name); + const keptExtension = Buffer.byteLength(extension) < MAX_FILE_NAME_BYTES ? extension : ""; + let stem = ""; + for (const char of name.slice(0, name.length - keptExtension.length)) { + if (Buffer.byteLength(stem + char + keptExtension) > MAX_FILE_NAME_BYTES) break; + stem += char; + } + return stem + keptExtension; }