From 2461d1003b30fffc97dccec28dc7f9523b9c36ee Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 19:56:30 -0700 Subject: [PATCH 1/2] fix(files): preserve slashes in folder paths --- .../app/api/v2/files/folders/route.test.ts | 31 +++++++++++++ apps/sim/app/api/v2/files/folders/route.ts | 11 ++++- apps/sim/app/api/v2/files/route.test.ts | 14 ++++++ apps/sim/app/api/v2/files/utils.ts | 3 +- .../w/components/sidebar/sidebar.tsx | 5 ++- .../hooks/queries/workspace-file-folders.ts | 9 +++- apps/sim/lib/copilot/chat/process-contents.ts | 3 +- .../tools/handlers/function-execute.ts | 8 +++- apps/sim/lib/copilot/vfs/path-utils.test.ts | 9 ++++ apps/sim/lib/copilot/vfs/path-utils.ts | 5 ++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 9 ++-- apps/sim/lib/folders/paths.test.ts | 11 +++++ .../workspace-file-folder-manager.test.ts | 10 +++++ .../workspace-file-folder-manager.ts | 6 ++- apps/sim/lib/uploads/zip-entry-path.test.ts | 6 +++ apps/sim/lib/uploads/zip-entry-path.ts | 15 ++++--- .../workspace-file-folders.test.ts | 14 ++++++ .../application/workspace-file-folders.ts | 10 ++--- .../write-workspace-file-by-path.ts | 10 ++--- .../folder-display-path.test.ts | 22 ++++++++++ .../workspace-files/folder-display-path.ts | 43 +++++++++++++++++++ 21 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 apps/sim/lib/workspace-files/folder-display-path.test.ts create mode 100644 apps/sim/lib/workspace-files/folder-display-path.ts diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts index 23b402c6acc..50dd9b0c98c 100644 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -156,6 +156,37 @@ describe('/api/v2/files/folders', () => { }) }) + it('preserves an escaped slash within a folder name', async () => { + mocks.listFolders.mockResolvedValueOnce({ + folders: [{ ...folder, name: 'Finance/Legal', path: 'Finance\\/Legal' }], + }) + + const response = await GET( + request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data[0]).toMatchObject({ + name: 'Finance/Legal', + path: '/Finance%2FLegal', + parentPath: '/', + }) + }) + + it('fails when a canonical path does not match the returned folder name', async () => { + mocks.listFolders.mockResolvedValueOnce({ + folders: [{ ...folder, name: 'Finance/Legal', path: '/Finance/Legal' }], + }) + + const response = await GET( + request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(500) + }) + it('creates a folder from its canonical path', async () => { const response = await POST( request('POST', '/api/v2/files/folders', { workspaceId: WORKSPACE_ID, path: '/Reports' }), diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 073ff24d3ba..3cfc7222d4d 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -5,7 +5,7 @@ import { v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { buildFolderPath, parentFolderPath } from '@/lib/folders/paths' +import { buildFolderPath, parentFolderPath, parseFolderPath } from '@/lib/folders/paths' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { @@ -14,12 +14,19 @@ import { listWorkspaceFileFoldersOperation, updateWorkspaceFileFolderOperation, } from '@/lib/workspace-files/application/workspace-file-folders' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' export const dynamic = 'force-dynamic' export const revalidate = 0 function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) { - const path = folder.path.startsWith('/') ? folder.path : buildFolderPath(folder.path.split('/')) + const segments = folder.path.startsWith('/') + ? parseFolderPath(folder.path) + : parseWorkspaceFileFolderDisplayPath(folder.path) + if (segments.at(-1) !== folder.name) { + throw new Error('Workspace file folder path does not match its folder name') + } + const path = buildFolderPath(segments) return { name: folder.name, path, diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 4b6508d5c20..8d62db0876c 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -154,6 +154,20 @@ describe('/api/v2/files', () => { }) }) + it('preserves escaped slashes in the containing folder path', async () => { + mocks.queryFiles.mockResolvedValueOnce({ + files: [{ ...FILE, folderId: 'folder-1', folderPath: 'Finance\\/Legal' }], + nextKeys: undefined, + cursorSort: 'name:asc', + }) + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(200) + expect((await response.json()).data[0].folderPath).toBe('/Finance%2FLegal') + }) + it('rejects malformed cursors before the application service', async () => { const response = await GET( new NextRequest( diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 2e11d23f03d..d21514036e4 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -2,6 +2,7 @@ import type { V2File } from '@/lib/api/contracts/v2/files' import { buildFolderPath } from '@/lib/folders/paths' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' /** Shared serialization for the v2 files surface. */ @@ -14,7 +15,7 @@ function serializeV2File(record: WorkspaceFileRecord, uploadedByEmail: string): ? buildFolderPath( (() => { if (!record.folderPath) throw new Error('File references an unresolved folder') - return record.folderPath.split('/') + return parseWorkspaceFileFolderDisplayPath(record.folderPath) })() ) : '/' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 7cc21bf9233..6a6824eae68 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -43,6 +43,7 @@ import { isChatEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -927,7 +928,9 @@ export const Sidebar = memo(function Sidebar({ id: f.id, name: f.name, href: `/workspace/${workspaceId}/files/${f.id}`, - folderPath: f.folderPath ? f.folderPath.split('/').filter(Boolean) : undefined, + folderPath: f.folderPath + ? parseWorkspaceFileFolderDisplayPath(f.folderPath) + : undefined, })), [fetchedFiles, workspaceId, permissionConfig.hideFilesTab] ) diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index 40010ab03c8..f9338436288 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -11,6 +11,10 @@ import { updateWorkspaceFileFolderContract, type WorkspaceFileFolderApi, } from '@/lib/api/contracts/workspace-file-folders' +import { + buildWorkspaceFileFolderDisplayPath, + parseWorkspaceFileFolderDisplayPath, +} from '@/lib/workspace-files/folder-display-path' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' type WorkspaceFileFolderScope = 'active' | 'archived' | 'all' @@ -109,7 +113,10 @@ export function useUpdateWorkspaceFileFolder() { const oldPath = target?.path const newPath = updates.name !== undefined && oldPath !== undefined - ? [...oldPath.split('/').slice(0, -1), updates.name].filter(Boolean).join('/') + ? buildWorkspaceFileFolderDisplayPath([ + ...parseWorkspaceFileFolderDisplayPath(oldPath).slice(0, -1), + updates.name, + ]) : oldPath queryClient.setQueryData( diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index fc6ef5ce837..689eaed8a02 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -46,6 +46,7 @@ import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/wor import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' @@ -1071,7 +1072,7 @@ async function resolveFileFolderResource( try { const rawPath = await getWorkspaceFileFolderPath(workspaceId, folderId) if (!rawPath) return null - const encoded = encodeVfsPathSegments(rawPath.split('/').filter(Boolean)) + const encoded = encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(rawPath)) return { type: 'active_resource', tag: '@active_resource', diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 067fcff9c7d..01773a65f00 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -46,6 +46,10 @@ import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-wo import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { + buildWorkspaceFileFolderDisplayPath, + parseWorkspaceFileFolderDisplayPath, +} from '@/lib/workspace-files/folder-display-path' import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' @@ -388,7 +392,7 @@ export async function resolveInputFiles( : undefined if (!dirPath) continue const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) - const folderDisplayPath = folderSegments.join('/') + const folderDisplayPath = buildWorkspaceFileFolderDisplayPath(folderSegments) const folder = folders.find((candidate) => candidate.path === folderDisplayPath) if (!folder) { const unmountable = unmountableNamespaceReason(dirPath) @@ -403,7 +407,7 @@ export async function resolveInputFiles( dirRef !== null && (dirRef as CanonicalDirectoryInput).sandboxPath ? (dirRef as CanonicalDirectoryInput).sandboxPath! - : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` + : `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}` const descendants = allFiles.filter((file) => { if (!file.folderPath) return false return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) diff --git a/apps/sim/lib/copilot/vfs/path-utils.test.ts b/apps/sim/lib/copilot/vfs/path-utils.test.ts index b2df921be1a..9c736ff1180 100644 --- a/apps/sim/lib/copilot/vfs/path-utils.test.ts +++ b/apps/sim/lib/copilot/vfs/path-utils.test.ts @@ -31,6 +31,15 @@ describe('VFS path utilities', () => { }) ).toBe('files/Reports/Q4%20Report%20(Final)/sales%2Feast.csv') }) + + it('keeps an escaped slash inside one workspace folder segment', () => { + expect( + canonicalWorkspaceFilePath({ + folderPath: 'Finance\\/Legal/Quarterly', + name: 'report.pdf', + }) + ).toBe('files/Finance%2FLegal/Quarterly/report.pdf') + }) }) describe('canonical resource VFS paths', () => { diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts index 490c0bf0bfb..0fa024879db 100644 --- a/apps/sim/lib/copilot/vfs/path-utils.ts +++ b/apps/sim/lib/copilot/vfs/path-utils.ts @@ -6,6 +6,7 @@ import { encodeVfsPathSegments as encodeNeutralVfsPathSegments, encodeVfsSegment as encodeNeutralVfsSegment, } from '@/lib/vfs/path' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' export function encodeVfsSegment(segment: string): string { return encodeNeutralVfsSegment(segment) @@ -41,7 +42,9 @@ export function canonicalWorkspaceFilePath(parts: { prefix?: 'files' | 'recently-deleted/files' }): string { const prefix = parts.prefix ?? 'files' - const folderSegments = parts.folderPath ? parts.folderPath.split('/').filter(Boolean) : [] + const folderSegments = parts.folderPath + ? parseWorkspaceFileFolderDisplayPath(parts.folderPath) + : [] const encoded = encodeVfsPathSegments([...folderSegments, parts.name]) return `${prefix}/${encoded}` } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 756d338893b..1cdb3dd34b2 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -141,6 +141,7 @@ import { listFolders, listWorkflows } from '@/lib/workflows/utils' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -1960,7 +1961,10 @@ export class WorkspaceVFS { listAllWorkspaceFiles.execute({ principal, input: { workspaceId, scope: 'active' } }), ]) for (const folder of folders) { - this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') + this.files.set( + `files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}/.folder`, + '' + ) } for (const file of files) { @@ -2399,8 +2403,7 @@ export class WorkspaceVFS { } for (const folder of archivedFileFolders) { - const safePath = folder.path - .split('/') + const safePath = parseWorkspaceFileFolderDisplayPath(folder.path) .map((segment) => sanitizeName(segment)) .join('/') this.files.set( diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts index 413114087f7..d8273169194 100644 --- a/apps/sim/lib/folders/paths.test.ts +++ b/apps/sim/lib/folders/paths.test.ts @@ -77,6 +77,17 @@ describe('canonical folder paths', () => { ).toThrow('cycle') }) + it('keeps slashes inside names as one segment in every resource folder index', () => { + const index = buildFolderPathIndex([ + { id: 'legal', name: 'Finance/Legal', parentId: null }, + { id: 'quarterly', name: 'Quarterly', parentId: 'legal' }, + ]) + + expect(index.pathById.get('legal')).toBe('/Finance%2FLegal') + expect(index.pathById.get('quarterly')).toBe('/Finance%2FLegal/Quarterly') + expect(index.idByPath.get('/Finance%2FLegal')).toBe('legal') + }) + it('enforces segment and byte limits', () => { expect(() => buildFolderPath(Array.from({ length: MAX_FOLDER_PATH_SEGMENTS + 1 }, () => 'x')) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts index f081b39ca0a..578a9bb2d71 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts @@ -27,6 +27,16 @@ describe('workspace file folder paths', () => { expect(paths.get('archive')).toBe('Archive') }) + it('escapes slashes within folder names without changing hierarchy delimiters', () => { + const paths = buildWorkspaceFileFolderPathMap([ + { id: 'legal', name: 'Finance/Legal', parentId: null }, + { id: 'quarterly', name: 'Quarterly', parentId: 'legal' }, + ]) + + expect(paths.get('legal')).toBe('Finance\\/Legal') + expect(paths.get('quarterly')).toBe('Finance\\/Legal/Quarterly') + }) + it('rejects names that would create ambiguous paths', () => { expect(normalizeWorkspaceFileItemName('Reports', 'Folder')).toBe('Reports') expect(() => normalizeWorkspaceFileItemName('A/B', 'Folder')).toThrow( diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 59692acd36e..0e658b59f59 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -18,6 +18,7 @@ import { requireNonRootFolderPath, } from '@/lib/folders/paths' import { collectDescendantFolderIds } from '@/lib/folders/subtree' +import { encodeWorkspaceFileFolderDisplaySegment } from '@/lib/workspace-files/folder-display-path' import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -245,7 +246,8 @@ export function buildWorkspaceFileFolderPathMap( const nextSeen = new Set(seen) nextSeen.add(folderId) const parentPath = folder.parentId ? resolve(folder.parentId, nextSeen) : '' - const path = parentPath ? `${parentPath}/${folder.name}` : folder.name + const encodedName = encodeWorkspaceFileFolderDisplaySegment(folder.name) + const path = parentPath ? `${parentPath}/${encodedName}` : encodedName paths.set(folderId, path) return path } @@ -338,7 +340,7 @@ async function buildWorkspaceFileFolderPath( : null } - return segments.join('/') + return segments.map(encodeWorkspaceFileFolderDisplaySegment).join('/') } async function mapFolderWithPath( diff --git a/apps/sim/lib/uploads/zip-entry-path.test.ts b/apps/sim/lib/uploads/zip-entry-path.test.ts index afd84774217..c51d6080891 100644 --- a/apps/sim/lib/uploads/zip-entry-path.test.ts +++ b/apps/sim/lib/uploads/zip-entry-path.test.ts @@ -19,6 +19,12 @@ describe('buildZipEntryPaths', () => { ]) }) + it('sanitizes a slash within one escaped folder name instead of nesting it', () => { + expect( + buildZipEntryPaths([{ name: 'contract.pdf', folderPath: 'Finance\\/Legal/Quarterly' }]) + ).toEqual(['Finance_Legal/Quarterly/contract.pdf']) + }) + it('keeps same-named files in different folders apart', () => { expect( buildZipEntryPaths([ diff --git a/apps/sim/lib/uploads/zip-entry-path.ts b/apps/sim/lib/uploads/zip-entry-path.ts index 08e64e0cb9e..5f7cc254f3b 100644 --- a/apps/sim/lib/uploads/zip-entry-path.ts +++ b/apps/sim/lib/uploads/zip-entry-path.ts @@ -1,5 +1,7 @@ +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + /** Characters that are illegal in file names on common desktop platforms. */ -const ILLEGAL_ENTRY_CHARS = /[<>:"\\|?*\x00-\x1f]/g +const ILLEGAL_ENTRY_CHARS = /[<>:"/\\|?*\x00-\x1f]/g /** A workspace file to place inside an archive. */ export interface ZipEntrySource { @@ -19,7 +21,7 @@ export interface BuildZipEntryPathsOptions { /** Split a folder path into non-empty segments. */ function toSegments(folderPath?: string | null): string[] { - return folderPath ? folderPath.split('/').filter(Boolean) : [] + return folderPath ? parseWorkspaceFileFolderDisplayPath(folderPath) : [] } /** @@ -34,13 +36,12 @@ function toLeafName(name: string): string { } /** - * Sanitize a `/`-joined entry path segment by segment: strips characters that are + * Sanitize entry path segments before joining them: strips characters that are * illegal on common desktop platforms, neutralizes `.`/`..` traversal segments, and * drops empty segments. Returns `''` when no usable segment remains. */ -function safeEntryPath(path: string): string { - return path - .split('/') +function safeEntryPath(segments: string[]): string { + return segments .map((segment) => { const cleaned = segment.trim().replace(ILLEGAL_ENTRY_CHARS, '_') return cleaned === '.' || cleaned === '..' ? '_' : cleaned @@ -110,7 +111,7 @@ export function buildZipEntryPaths( return sources.map((source) => { const leafName = toLeafName(source.name) const folderSegments = toSegments(source.folderPath).slice(rebaseLength) - const basePath = safeEntryPath([...folderSegments, leafName].join('/')) || leafName + const basePath = safeEntryPath([...folderSegments, leafName]) || leafName let candidate = basePath let suffix = 1 diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts index 4f2bb1b46d1..bec03b034a7 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -135,6 +135,20 @@ describe('workspace file folder operations', () => { expect(result.folders.map((item) => item.id)).toEqual(['child-1']) }) + it('matches a parent whose name contains an escaped slash', async () => { + mockList.mockResolvedValue([ + { ...folder, id: 'child-1', name: 'Q1', path: 'Finance\\/Legal/Q1' }, + { ...folder, id: 'other-1', name: 'Other', path: 'Finance/Legal/Other' }, + ]) + + const result = await listWorkspaceFileFoldersOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', parentPath: '/Finance%2FLegal' }, + }) + + expect(result.folders.map((item) => item.id)).toEqual(['child-1']) + }) + it('ensures an entire decoded folder chain for a file write', async () => { mockEnsure.mockResolvedValue({ folderId: 'nested-folder', diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts index 8e9687f670d..baa2e85d5c3 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -21,6 +21,7 @@ import { } from '@/lib/uploads/contexts/workspace' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' const logger = createLogger('WorkspaceFileFolders') @@ -116,12 +117,11 @@ async function executeListWorkspaceFileFolders(args: { scope: args.input.scope, }) if (args.input.parentPath !== undefined) { - const parentPath = parseFolderPath(args.input.parentPath).join('/') + const parentSegments = parseFolderPath(args.input.parentPath) folders = folders.filter((folder) => { - const parent = folder.path.includes('/') - ? folder.path.slice(0, folder.path.lastIndexOf('/')) - : '' - return parent === parentPath + const folderSegments = parseWorkspaceFileFolderDisplayPath(folder.path) + if (folderSegments.length !== parentSegments.length + 1) return false + return parentSegments.every((segment, index) => folderSegments[index] === segment) }) } if (args.input.search) { diff --git a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts index eaae6c28d71..d38371d29cb 100644 --- a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts +++ b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { encodeVfsPathSegments, encodeVfsSegment } from '@/lib/vfs/path' import { admitCreateWorkspaceFile, createWorkspaceFile, @@ -14,6 +15,7 @@ import { updateWorkspaceFileContent, updateWorkspaceFileContentFromBuffer, } from '@/lib/workspace-files/application/update-workspace-file-content' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' export interface WriteWorkspaceFileByPathInput { @@ -56,11 +58,7 @@ function toResult( ): WriteWorkspaceFileByPathResult { const folderPath = file.folderPath ?? '' const encodedFolderPath = folderPath - ? folderPath - .split('/') - .filter(Boolean) - .map((segment) => encodeURIComponent(segment)) - .join('/') + ? encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folderPath)) : '' return { id: file.id, @@ -68,7 +66,7 @@ function toResult( size: file.size, contentType: file.type, downloadUrl: file.url, - vfsPath: `files/${encodedFolderPath ? `${encodedFolderPath}/` : ''}${encodeURIComponent(file.name)}`, + vfsPath: `files/${encodedFolderPath ? `${encodedFolderPath}/` : ''}${encodeVfsSegment(file.name)}`, mode, } } diff --git a/apps/sim/lib/workspace-files/folder-display-path.test.ts b/apps/sim/lib/workspace-files/folder-display-path.test.ts new file mode 100644 index 00000000000..cbba4141a9e --- /dev/null +++ b/apps/sim/lib/workspace-files/folder-display-path.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkspaceFileFolderDisplayPath, + parseWorkspaceFileFolderDisplayPath, +} from '@/lib/workspace-files/folder-display-path' + +describe('workspace file folder display paths', () => { + it('round-trips slashes and backslashes within folder names', () => { + const segments = ['Finance/Legal', String.raw`FY\2026`, 'Quarterly'] + const path = buildWorkspaceFileFolderDisplayPath(segments) + + expect(path).toBe(String.raw`Finance\/Legal/FY\\2026/Quarterly`) + expect(parseWorkspaceFileFolderDisplayPath(path)).toEqual(segments) + }) + + it.each(['Finance\\', 'Finance\\x', '/Finance', 'Finance/', 'Finance//Legal'])( + 'rejects malformed display path %s', + (path) => { + expect(() => parseWorkspaceFileFolderDisplayPath(path)).toThrow() + } + ) +}) diff --git a/apps/sim/lib/workspace-files/folder-display-path.ts b/apps/sim/lib/workspace-files/folder-display-path.ts new file mode 100644 index 00000000000..b6cc9f870b6 --- /dev/null +++ b/apps/sim/lib/workspace-files/folder-display-path.ts @@ -0,0 +1,43 @@ +/** Escapes one decoded folder name for the internal slash-delimited display path. */ +export function encodeWorkspaceFileFolderDisplaySegment(name: string): string { + if (name.length === 0) throw new Error('Workspace file folder names cannot be empty') + return name.replaceAll('\\', '\\\\').replaceAll('/', '\\/') +} + +/** Builds an internal display path where `\/` represents a slash inside a folder name. */ +export function buildWorkspaceFileFolderDisplayPath(segments: readonly string[]): string { + return segments.map(encodeWorkspaceFileFolderDisplaySegment).join('/') +} + +/** Parses an internal display path without confusing an escaped slash for a path delimiter. */ +export function parseWorkspaceFileFolderDisplayPath(path: string): string[] { + if (path.length === 0) return [] + + const segments: string[] = [] + let segment = '' + + for (let index = 0; index < path.length; index += 1) { + const character = path[index] + if (character === '/') { + if (segment.length === 0) throw new Error('Workspace file folder path contains an empty name') + segments.push(segment) + segment = '' + continue + } + if (character !== '\\') { + segment += character + continue + } + + const escaped = path[index + 1] + if (escaped !== '/' && escaped !== '\\') { + throw new Error('Workspace file folder path contains an invalid escape') + } + segment += escaped + index += 1 + } + + if (segment.length === 0) throw new Error('Workspace file folder path contains an empty name') + segments.push(segment) + return segments +} From a6ab404821b67d8ac4d2f7e4d8c87ad3825f137f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 11 Aug 2026 20:04:50 -0700 Subject: [PATCH 2/2] fix(files): resolve escaped folder lookups --- .../workspace/workspace-file-manager.test.ts | 14 +++++ .../workspace/workspace-file-manager.ts | 51 ++++++++----------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts index 72caa27a0a6..f867639089e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts @@ -75,4 +75,18 @@ describe('workspace file reference normalization', () => { archiveFile ) }) + + it('resolves an encoded slash within one folder segment', () => { + const legalFile: WorkspaceFileRecord = { + ...makeFileRecord(), + id: 'file-legal', + name: 'contract.pdf', + folderId: 'folder-legal', + folderPath: 'Finance\\/Legal', + } + + expect(findWorkspaceFileRecord([legalFile], 'files/Finance%2FLegal/contract.pdf/content')).toBe( + legalFile + ) + }) }) 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 56ff39ccf43..cc83cc5e98d 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -1284,6 +1284,10 @@ export async function queryWorkspaceFiles( * Files are addressed by their sanitized canonical path; id-based VFS paths are not supported. */ export function normalizeWorkspaceFileReference(fileReference: string): string { + return normalizeWorkspaceFileReferenceSegments(fileReference).join('/') +} + +function normalizeWorkspaceFileReferenceSegments(fileReference: string): string[] { const trimmed = fileReference.trim().replace(/^\/+/, '') const withoutDeletedPrefix = trimmed.startsWith('recently-deleted/') ? trimmed.slice('recently-deleted/'.length) @@ -1292,15 +1296,15 @@ export function normalizeWorkspaceFileReference(fileReference: string): string { if (withoutDeletedPrefix.startsWith('files/')) { const withoutPrefix = withoutDeletedPrefix.slice('files/'.length) if (withoutPrefix.endsWith('/meta.json')) { - return decodeVfsPathSegments(withoutPrefix.slice(0, -'/meta.json'.length)).join('/') + return decodeVfsPathSegments(withoutPrefix.slice(0, -'/meta.json'.length)) } if (withoutPrefix.endsWith('/content')) { - return decodeVfsPathSegments(withoutPrefix.slice(0, -'/content'.length)).join('/') + return decodeVfsPathSegments(withoutPrefix.slice(0, -'/content'.length)) } - return decodeVfsPathSegments(withoutPrefix).join('/') + return decodeVfsPathSegments(withoutPrefix) } - return decodeVfsPathSegments(withoutDeletedPrefix).join('/') + return decodeVfsPathSegments(withoutDeletedPrefix) } /** @@ -1325,26 +1329,20 @@ export function findWorkspaceFileRecord( return exactIdMatch } - const normalizedReference = normalizeWorkspaceFileReference(fileReference) + const referenceSegments = normalizeWorkspaceFileReferenceSegments(fileReference) + const normalizedReference = referenceSegments.join('/') const normalizedIdMatch = files.find((file) => file.id === normalizedReference) if (normalizedIdMatch) { return normalizedIdMatch } - const segmentKey = normalizedReference - .split('/') - .map((segment) => normalizeVfsSegment(segment)) - .join('/') - const normalizedPathMatch = files.find((file) => { - const folderPath = file.folderPath - ?.split('/') - .map((segment) => normalizeVfsSegment(segment)) - .join('/') - const fullPath = folderPath - ? `${folderPath}/${normalizeVfsSegment(file.name)}` - : normalizeVfsSegment(file.name) - return fullPath === segmentKey - }) + const segmentKey = referenceSegments.map(normalizeVfsSegment).join('/') + const normalizedPathMatch = files.find( + (file) => + canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }).slice( + 'files/'.length + ) === segmentKey + ) if (normalizedPathMatch) return normalizedPathMatch return files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null @@ -1352,13 +1350,8 @@ export function findWorkspaceFileRecord( async function getWorkspaceFileByExactReference( workspaceId: string, - fileReference: string + segments: string[] ): Promise { - const segments = fileReference - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean) - if (segments.length === 0) return null if (segments.length === 1) { return getWorkspaceFileByName(workspaceId, segments[0], { folderId: null }) @@ -1375,16 +1368,14 @@ export async function resolveWorkspaceFileReference( workspaceId: string, fileReference: string ): Promise { - const normalizedReference = normalizeWorkspaceFileReference(fileReference) + const referenceSegments = normalizeWorkspaceFileReferenceSegments(fileReference) + const normalizedReference = referenceSegments.join('/') if (normalizedReference.startsWith('wf_')) { const file = await getWorkspaceFile(workspaceId, normalizedReference, { throwOnError: true }) if (file) return file } - const exactReferenceFile = await getWorkspaceFileByExactReference( - workspaceId, - normalizedReference - ) + const exactReferenceFile = await getWorkspaceFileByExactReference(workspaceId, referenceSegments) if (exactReferenceFile) return exactReferenceFile const files = await listWorkspaceFiles(workspaceId)