From e8d7f7f9ac996b1b5673b55c2f97680d03c05bf3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 2 Oct 2026 01:24:16 -0700 Subject: [PATCH 1/2] fix(files): resolve chat uploads whose names are not in VFS form --- .../__integration__/file-names.integration.ts | 92 ++++++++++++++++++- .../workspace/workspace-file-manager.ts | 21 +++-- .../workspace-file-reference.test.ts | 8 -- apps/sim/lib/vfs/path.ts | 6 +- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts index f4baa51c973..2f4c52114b7 100644 --- a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts @@ -1,10 +1,13 @@ -/** Real PostgreSQL name allocation and name lookups for workspace files, plus the URL fetch path. */ +/** + * Real PostgreSQL name allocation and name lookups for workspace files and chat uploads, plus the + * URL fetch path. + */ import { mkdtempSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { db, dbFor } from '@sim/db' -import { organization, user, workspace, workspaceFiles } from '@sim/db/schema' +import { copilotChats, organization, user, workspace, workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' @@ -29,8 +32,12 @@ import { workspaceFileNameFolderCondition, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { + generateWorkspaceFileKey, getWorkspaceFileByName, + resolveWorkspaceFileReference, + trackChatUpload, uploadWorkspaceFile, + workspaceFileVfsPath, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' @@ -66,6 +73,19 @@ describe('workspace file names in PostgreSQL', () => { }) } + async function seedChat(workspaceId: string, userId: string) { + const chatId = generateId() + await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' }) + return chatId + } + + async function trackUpload(workspaceId: string, userId: string, chatId: string, name: string) { + const key = generateWorkspaceFileKey(workspaceId, name) + await trackChatUpload(workspaceId, userId, chatId, key, name, 'image/png', 10) + const [row] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, key)) + return row + } + async function parseExternalUrl(executionId?: string) { const fixture = await seedWorkspace() const url = 'https://example.com/page.txt' @@ -194,4 +214,72 @@ describe('workspace file names in PostgreSQL', () => { for (const name of names) expect(name).toMatch(shortIdSuffixed) expect(new Set([next.name, ...names]).size).toBe(names.length + 1) }) + + it('resolves a chat upload by the path its upload notice prints when the name is not in VFS form', async () => { + const fixture = await seedWorkspace() + const chatId = await seedChat(fixture.workspaceId, fixture.aliceId) + const otherChatId = await seedChat(fixture.workspaceId, fixture.aliceId) + // macOS screenshot names carry U+202F before AM/PM, pasted names keep doubled spaces, some + // pickers report decomposed (NFD) accents, and control characters drop out of VFS names. + const names = [ + 'Screenshot 2026-01-15 at 9.41.07\u202fAM.png', + 'Quarterly Report.pdf', + 'Cafe\u0301 menu.png', + 'ring\u0007ing.png', + ] + for (const name of names) { + const row = await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, name) + const noticePath = workspaceFileVfsPath({ folderPath: null, name, vfsNamespace: 'uploads' }) + for (const reference of [noticePath, `uploads/${name}`]) { + for (const options of [{ chatId }, {}]) { + const record = await resolveWorkspaceFileReference(fixture.workspaceId, reference, { + includeChatUploads: true, + ...options, + }) + expect(record?.id).toBe(row.id) + expect(record?.name).toBe(name) + } + } + expect( + await resolveWorkspaceFileReference(fixture.workspaceId, noticePath, { + includeChatUploads: true, + chatId: otherChatId, + }) + ).toBeNull() + } + }) + + it('matches a chat upload name exactly, never as a pattern or a fragment', async () => { + const fixture = await seedWorkspace() + const chatId = await seedChat(fixture.workspaceId, fixture.aliceId) + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'axb.png') + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'my notes.png.bak') + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'notes 100%.png') + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'back\\slash.png') + + for (const reference of ['uploads/a_b.png', 'uploads/notes.png', 'uploads/notes%20%25.png']) { + expect( + await resolveWorkspaceFileReference(fixture.workspaceId, reference, { + includeChatUploads: true, + chatId, + }) + ).toBeNull() + } + expect( + ( + await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/notes%20100%25.png', { + includeChatUploads: true, + chatId, + }) + )?.name + ).toBe('notes 100%.png') + expect( + ( + await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/back%5Cslash.png', { + includeChatUploads: true, + chatId, + }) + )?.name + ).toBe('back\\slash.png') + }) }) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 71af2ae2c9e..6e6b2679ace 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -16,6 +16,7 @@ import { } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { omit } from '@sim/utils/object' +import { escapeLikePattern } from '@sim/utils/string' import { and, desc, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' @@ -100,6 +101,7 @@ import { import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import type { ServableFile } from '@/lib/uploads/utils/file-utils.server' +import { normalizeDisplaySegment } from '@/lib/vfs/path' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES, @@ -1577,13 +1579,21 @@ export function parseChatUploadReference(fileReference: string): string | null { /** * Display names are unique per chat. Mothership supplies that namespace; callers * without a chat scope retain the workspace-wide newest-name lookup. + * + * `name` is in VFS display form ({@link normalizeDisplaySegment}), since that is what the + * upload notice's path encodes, while the stored name keeps the uploaded spelling (a macOS + * screenshot carries U+202F before AM/PM). The query narrows to names that, composed and + * stripped of control characters, hold each of `name`'s words in order — a superset of the + * exact match, which then compares the stored name in display form. */ async function getChatUploadByName( workspaceId: string, name: string, chatId?: string ): Promise { - const [file] = await db + const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})` + const wordsInOrder = `%${name.split(' ').map(escapeLikePattern).join('%')}%` + const candidates = await db .select() .from(workspaceFiles) .where( @@ -1591,16 +1601,15 @@ async function getChatUploadByName( eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'mothership'), chatId === undefined ? undefined : eq(workspaceFiles.chatId, chatId), - or( - eq(workspaceFiles.displayName, name), - and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, name)) - ), + sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') LIKE ${wordsInOrder} ESCAPE '\\'`, isNull(workspaceFiles.deletedAt) ) ) .orderBy(desc(workspaceFiles.uploadedAt)) - .limit(1) + const file = candidates.find( + (candidate) => normalizeDisplaySegment(candidate.displayName ?? candidate.originalName) === name + ) return file ? mapChatUploadRecord(file, workspaceId) : null } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts index 123a034d2dc..c13b773d230 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts @@ -129,18 +129,10 @@ describe('resolveWorkspaceFileReference', () => { expect(conditions).toContainEqual( expect.objectContaining({ type: 'isNull', column: schemaMock.workspaceFiles.deletedAt }) ) - const nameMatch = conditions.find((condition) => condition.type === 'or') - expect(nameMatch).toMatchObject({ - conditions: [ - { type: 'eq', left: schemaMock.workspaceFiles.displayName, right: 'face (2).png' }, - expect.anything(), - ], - }) expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({ type: 'desc', column: schemaMock.workspaceFiles.uploadedAt, }) - expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) /** Found by its own query: the listing fallback never ran. */ expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/lib/vfs/path.ts b/apps/sim/lib/vfs/path.ts index 2b8848b2ed3..ad9cb4848be 100644 --- a/apps/sim/lib/vfs/path.ts +++ b/apps/sim/lib/vfs/path.ts @@ -8,7 +8,11 @@ export class VfsPathError extends Error { } } -function normalizeDisplaySegment(segment: string): string { +/** + * The display form every VFS path segment is built from and decoded to: NFC, trimmed, + * control characters removed, and each whitespace run (including U+202F) one space. + */ +export function normalizeDisplaySegment(segment: string): string { return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') } From dd6ae5569b209e4edef0e884e77c2a532c4406b8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 2 Oct 2026 01:51:15 -0700 Subject: [PATCH 2/2] fix(files): match chat upload names exactly in SQL with one bounded row --- .../__integration__/file-names.integration.ts | 8 ++++++- .../workspace/workspace-file-manager.ts | 24 ++++++++----------- apps/sim/lib/vfs/path.ts | 21 ++++++++++++---- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts index 2f4c52114b7..e8f2d62cdc8 100644 --- a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts @@ -226,6 +226,7 @@ describe('workspace file names in PostgreSQL', () => { 'Quarterly Report.pdf', 'Cafe\u0301 menu.png', 'ring\u0007ing.png', + 'trail.png \u0007', ] for (const name of names) { const row = await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, name) @@ -257,7 +258,12 @@ describe('workspace file names in PostgreSQL', () => { await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'notes 100%.png') await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'back\\slash.png') - for (const reference of ['uploads/a_b.png', 'uploads/notes.png', 'uploads/notes%20%25.png']) { + for (const reference of [ + 'uploads/a_b.png', + 'uploads/a.b.png', + 'uploads/notes.png', + 'uploads/notes%20%25.png', + ]) { expect( await resolveWorkspaceFileReference(fixture.workspaceId, reference, { includeChatUploads: true, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 6e6b2679ace..57161c2058c 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -16,7 +16,6 @@ import { } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { omit } from '@sim/utils/object' -import { escapeLikePattern } from '@sim/utils/string' import { and, desc, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' @@ -101,7 +100,7 @@ import { import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import type { ServableFile } from '@/lib/uploads/utils/file-utils.server' -import { normalizeDisplaySegment } from '@/lib/vfs/path' +import { displaySegmentPattern } from '@/lib/vfs/path' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES, @@ -1580,20 +1579,19 @@ export function parseChatUploadReference(fileReference: string): string | null { * Display names are unique per chat. Mothership supplies that namespace; callers * without a chat scope retain the workspace-wide newest-name lookup. * - * `name` is in VFS display form ({@link normalizeDisplaySegment}), since that is what the - * upload notice's path encodes, while the stored name keeps the uploaded spelling (a macOS - * screenshot carries U+202F before AM/PM). The query narrows to names that, composed and - * stripped of control characters, hold each of `name`'s words in order — a superset of the - * exact match, which then compares the stored name in display form. + * `name` is decoded from the path the upload notice prints, which VFS encoding normalizes + * (NFC, control characters removed, whitespace runs collapsed and trimmed), while the stored + * name keeps the uploaded spelling: a macOS screenshot carries U+202F before AM/PM. The stored + * name is composed and stripped of control characters in SQL, and + * {@link displaySegmentPattern} matches its whitespace the way the encoding collapses it. */ async function getChatUploadByName( workspaceId: string, name: string, chatId?: string ): Promise { - const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})` - const wordsInOrder = `%${name.split(' ').map(escapeLikePattern).join('%')}%` - const candidates = await db + const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})` + const [file] = await db .select() .from(workspaceFiles) .where( @@ -1601,15 +1599,13 @@ async function getChatUploadByName( eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'mothership'), chatId === undefined ? undefined : eq(workspaceFiles.chatId, chatId), - sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') LIKE ${wordsInOrder} ESCAPE '\\'`, + sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') ~ ${displaySegmentPattern(name)}`, isNull(workspaceFiles.deletedAt) ) ) .orderBy(desc(workspaceFiles.uploadedAt)) + .limit(1) - const file = candidates.find( - (candidate) => normalizeDisplaySegment(candidate.displayName ?? candidate.originalName) === name - ) return file ? mapChatUploadRecord(file, workspaceId) : null } diff --git a/apps/sim/lib/vfs/path.ts b/apps/sim/lib/vfs/path.ts index ad9cb4848be..483b2c1a30e 100644 --- a/apps/sim/lib/vfs/path.ts +++ b/apps/sim/lib/vfs/path.ts @@ -1,5 +1,10 @@ +import { escapeRegExp } from '@sim/utils/string' + const CONTROL_CHARS = /[\x00-\x1f\x7f]/g -const WHITESPACE = /\s+/g +/** The characters `\s` matches, spelled out so a PostgreSQL pattern can share the class. */ +const WHITESPACE_CLASS = + '[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]' +const WHITESPACE = new RegExp(`${WHITESPACE_CLASS}+`, 'g') export class VfsPathError extends Error { constructor(message: string) { @@ -8,12 +13,18 @@ export class VfsPathError extends Error { } } +function normalizeDisplaySegment(segment: string): string { + return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') +} + /** - * The display form every VFS path segment is built from and decoded to: NFC, trimmed, - * control characters removed, and each whitespace run (including U+202F) one space. + * Anchored regular expression, valid in JavaScript and PostgreSQL, that matches every + * NFC-composed, control-character-free name whose segment decodes to `name`: the words of + * `name` (already in decoded form) separated by whitespace runs, with any whitespace around them. */ -export function normalizeDisplaySegment(segment: string): string { - return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') +export function displaySegmentPattern(name: string): string { + const words = name.split(' ').map(escapeRegExp) + return `^${WHITESPACE_CLASS}*${words.join(`${WHITESPACE_CLASS}+`)}${WHITESPACE_CLASS}*$` } export function encodeVfsSegment(segment: string): string {