Skip to content

Commit f306b51

Browse files
fix(files): preserve slashes in folder paths (#6589)
* fix(files): preserve slashes in folder paths * fix(files): resolve escaped folder lookups
1 parent 766526b commit f306b51

23 files changed

Lines changed: 257 additions & 62 deletions

apps/sim/app/api/v2/files/folders/route.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,37 @@ describe('/api/v2/files/folders', () => {
156156
})
157157
})
158158

159+
it('preserves an escaped slash within a folder name', async () => {
160+
mocks.listFolders.mockResolvedValueOnce({
161+
folders: [{ ...folder, name: 'Finance/Legal', path: 'Finance\\/Legal' }],
162+
})
163+
164+
const response = await GET(
165+
request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`),
166+
context
167+
)
168+
169+
expect(response.status).toBe(200)
170+
expect((await response.json()).data[0]).toMatchObject({
171+
name: 'Finance/Legal',
172+
path: '/Finance%2FLegal',
173+
parentPath: '/',
174+
})
175+
})
176+
177+
it('fails when a canonical path does not match the returned folder name', async () => {
178+
mocks.listFolders.mockResolvedValueOnce({
179+
folders: [{ ...folder, name: 'Finance/Legal', path: '/Finance/Legal' }],
180+
})
181+
182+
const response = await GET(
183+
request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`),
184+
context
185+
)
186+
187+
expect(response.status).toBe(500)
188+
})
189+
159190
it('creates a folder from its canonical path', async () => {
160191
const response = await POST(
161192
request('POST', '/api/v2/files/folders', { workspaceId: WORKSPACE_ID, path: '/Reports' }),

apps/sim/app/api/v2/files/folders/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
v2RelocateFileFolderContract,
66
} from '@/lib/api/contracts/v2/files'
77
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
8-
import { buildFolderPath, parentFolderPath } from '@/lib/folders/paths'
8+
import { buildFolderPath, parentFolderPath, parseFolderPath } from '@/lib/folders/paths'
99
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
1010
import { fileOperations } from '@/lib/workspace-files/application/operations'
1111
import {
@@ -14,12 +14,19 @@ import {
1414
listWorkspaceFileFoldersOperation,
1515
updateWorkspaceFileFolderOperation,
1616
} from '@/lib/workspace-files/application/workspace-file-folders'
17+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
1718

1819
export const dynamic = 'force-dynamic'
1920
export const revalidate = 0
2021

2122
function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) {
22-
const path = folder.path.startsWith('/') ? folder.path : buildFolderPath(folder.path.split('/'))
23+
const segments = folder.path.startsWith('/')
24+
? parseFolderPath(folder.path)
25+
: parseWorkspaceFileFolderDisplayPath(folder.path)
26+
if (segments.at(-1) !== folder.name) {
27+
throw new Error('Workspace file folder path does not match its folder name')
28+
}
29+
const path = buildFolderPath(segments)
2330
return {
2431
name: folder.name,
2532
path,

apps/sim/app/api/v2/files/route.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@ describe('/api/v2/files', () => {
154154
})
155155
})
156156

157+
it('preserves escaped slashes in the containing folder path', async () => {
158+
mocks.queryFiles.mockResolvedValueOnce({
159+
files: [{ ...FILE, folderId: 'folder-1', folderPath: 'Finance\\/Legal' }],
160+
nextKeys: undefined,
161+
cursorSort: 'name:asc',
162+
})
163+
const response = await GET(
164+
new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`)
165+
)
166+
167+
expect(response.status).toBe(200)
168+
expect((await response.json()).data[0].folderPath).toBe('/Finance%2FLegal')
169+
})
170+
157171
it('rejects malformed cursors before the application service', async () => {
158172
const response = await GET(
159173
new NextRequest(

apps/sim/app/api/v2/files/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { V2File } from '@/lib/api/contracts/v2/files'
22
import { buildFolderPath } from '@/lib/folders/paths'
33
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
44
import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries'
5+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
56

67
/** Shared serialization for the v2 files surface. */
78

@@ -14,7 +15,7 @@ function serializeV2File(record: WorkspaceFileRecord, uploadedByEmail: string):
1415
? buildFolderPath(
1516
(() => {
1617
if (!record.folderPath) throw new Error('File references an unresolved folder')
17-
return record.folderPath.split('/')
18+
return parseWorkspaceFileFolderDisplayPath(record.folderPath)
1819
})()
1920
)
2021
: '/'

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { isChatEnabled } from '@/lib/core/config/env-flags'
4343
import { isMacPlatform } from '@/lib/core/utils/platform'
4444
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
4545
import { captureEvent } from '@/lib/posthog/client'
46+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
4647
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
4748
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
4849
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -927,7 +928,9 @@ export const Sidebar = memo(function Sidebar({
927928
id: f.id,
928929
name: f.name,
929930
href: `/workspace/${workspaceId}/files/${f.id}`,
930-
folderPath: f.folderPath ? f.folderPath.split('/').filter(Boolean) : undefined,
931+
folderPath: f.folderPath
932+
? parseWorkspaceFileFolderDisplayPath(f.folderPath)
933+
: undefined,
931934
})),
932935
[fetchedFiles, workspaceId, permissionConfig.hideFilesTab]
933936
)

apps/sim/hooks/queries/workspace-file-folders.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
updateWorkspaceFileFolderContract,
1212
type WorkspaceFileFolderApi,
1313
} from '@/lib/api/contracts/workspace-file-folders'
14+
import {
15+
buildWorkspaceFileFolderDisplayPath,
16+
parseWorkspaceFileFolderDisplayPath,
17+
} from '@/lib/workspace-files/folder-display-path'
1418
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
1519

1620
type WorkspaceFileFolderScope = 'active' | 'archived' | 'all'
@@ -109,7 +113,10 @@ export function useUpdateWorkspaceFileFolder() {
109113
const oldPath = target?.path
110114
const newPath =
111115
updates.name !== undefined && oldPath !== undefined
112-
? [...oldPath.split('/').slice(0, -1), updates.name].filter(Boolean).join('/')
116+
? buildWorkspaceFileFolderDisplayPath([
117+
...parseWorkspaceFileFolderDisplayPath(oldPath).slice(0, -1),
118+
updates.name,
119+
])
113120
: oldPath
114121

115122
queryClient.setQueryData<WorkspaceFileFolderApi[]>(

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/wor
4646
import { getSkillById } from '@/lib/workflows/skills/operations'
4747
import { listFolders } from '@/lib/workflows/utils'
4848
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
49+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
4950
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
5051
import { escapeRegExp } from '@/executor/constants'
5152
import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel'
@@ -1071,7 +1072,7 @@ async function resolveFileFolderResource(
10711072
try {
10721073
const rawPath = await getWorkspaceFileFolderPath(workspaceId, folderId)
10731074
if (!rawPath) return null
1074-
const encoded = encodeVfsPathSegments(rawPath.split('/').filter(Boolean))
1075+
const encoded = encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(rawPath))
10751076
return {
10761077
type: 'active_resource',
10771078
tag: '@active_resource',

apps/sim/lib/copilot/tools/handlers/function-execute.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-wo
4646
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
4747
import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record'
4848
import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders'
49+
import {
50+
buildWorkspaceFileFolderDisplayPath,
51+
parseWorkspaceFileFolderDisplayPath,
52+
} from '@/lib/workspace-files/folder-display-path'
4953
import { extractCodeSecretNames } from '@/executor/utils/code-secret-references'
5054
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5155
import { executeTool as executeAppTool } from '@/tools'
@@ -388,7 +392,7 @@ export async function resolveInputFiles(
388392
: undefined
389393
if (!dirPath) continue
390394
const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, ''))
391-
const folderDisplayPath = folderSegments.join('/')
395+
const folderDisplayPath = buildWorkspaceFileFolderDisplayPath(folderSegments)
392396
const folder = folders.find((candidate) => candidate.path === folderDisplayPath)
393397
if (!folder) {
394398
const unmountable = unmountableNamespaceReason(dirPath)
@@ -403,7 +407,7 @@ export async function resolveInputFiles(
403407
dirRef !== null &&
404408
(dirRef as CanonicalDirectoryInput).sandboxPath
405409
? (dirRef as CanonicalDirectoryInput).sandboxPath!
406-
: `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}`
410+
: `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}`
407411
const descendants = allFiles.filter((file) => {
408412
if (!file.folderPath) return false
409413
return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`)

apps/sim/lib/copilot/vfs/path-utils.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ describe('VFS path utilities', () => {
3131
})
3232
).toBe('files/Reports/Q4%20Report%20(Final)/sales%2Feast.csv')
3333
})
34+
35+
it('keeps an escaped slash inside one workspace folder segment', () => {
36+
expect(
37+
canonicalWorkspaceFilePath({
38+
folderPath: 'Finance\\/Legal/Quarterly',
39+
name: 'report.pdf',
40+
})
41+
).toBe('files/Finance%2FLegal/Quarterly/report.pdf')
42+
})
3443
})
3544

3645
describe('canonical resource VFS paths', () => {

apps/sim/lib/copilot/vfs/path-utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
encodeVfsPathSegments as encodeNeutralVfsPathSegments,
77
encodeVfsSegment as encodeNeutralVfsSegment,
88
} from '@/lib/vfs/path'
9+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
910

1011
export function encodeVfsSegment(segment: string): string {
1112
return encodeNeutralVfsSegment(segment)
@@ -41,7 +42,9 @@ export function canonicalWorkspaceFilePath(parts: {
4142
prefix?: 'files' | 'recently-deleted/files'
4243
}): string {
4344
const prefix = parts.prefix ?? 'files'
44-
const folderSegments = parts.folderPath ? parts.folderPath.split('/').filter(Boolean) : []
45+
const folderSegments = parts.folderPath
46+
? parseWorkspaceFileFolderDisplayPath(parts.folderPath)
47+
: []
4548
const encoded = encodeVfsPathSegments([...folderSegments, parts.name])
4649
return `${prefix}/${encoded}`
4750
}

0 commit comments

Comments
 (0)