Skip to content

Commit e36a75e

Browse files
committed
fix(files): match VFS paths as listings print them, without virtual suffixes or ambiguity
1 parent 04ac686 commit e36a75e

2 files changed

Lines changed: 63 additions & 6 deletions

File tree

‎apps/sim/lib/uploads/contexts/workspace/file-manager.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,18 +2343,37 @@ async function getWorkspaceFileByExactReference(
23432343
}
23442344

23452345
/**
2346-
* The live workspace file at an exact `files/…` VFS path, or null. Unlike
2347-
* {@link resolveWorkspaceFileReference} there is no name or listing fallback, so a mutation
2348-
* addressed by path lands only on the one file it names. Throws `VfsPathError` if malformed.
2346+
* The live workspace file whose canonical `files/…` path — as listings print it — is `vfsPath`,
2347+
* or null. Unlike {@link findWorkspaceFileRecord} there is no virtual `/content` or `/meta.json`
2348+
* suffix and no leaf-name fallback, so a mutation addressed by path lands only on the file it
2349+
* names. Two names that display alike are ambiguous and refused. Throws `VfsPathError` if
2350+
* malformed.
23492351
*/
23502352
export async function getWorkspaceFileByVfsPath(
23512353
workspaceId: string,
23522354
vfsPath: string
23532355
): Promise<WorkspaceFileRecord | null> {
2354-
return getWorkspaceFileByExactReference(
2355-
workspaceId,
2356-
normalizeWorkspaceFileReferenceSegments(vfsPath)
2356+
const segments = decodeVfsReferenceSegments(vfsPath.slice('files/'.length))
2357+
const leaf = segments.at(-1)
2358+
if (leaf === undefined) return null
2359+
const pathKey = segments.map(normalizeVfsSegment).join('/')
2360+
const candidates = await listWorkspaceFileCandidates(workspaceId, {
2361+
ids: [],
2362+
leafNames: [normalizeDisplaySegment(leaf)],
2363+
})
2364+
const matches = candidates.filter(
2365+
(file) =>
2366+
canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }).slice(
2367+
'files/'.length
2368+
) === pathKey
23572369
)
2370+
if (matches.length > 1) {
2371+
throw new OrchestrationError(
2372+
'validation',
2373+
`"${vfsPath}" names more than one file whose names display alike; pass the file id instead`
2374+
)
2375+
}
2376+
return matches[0] ?? null
23582377
}
23592378

23602379
/**

‎apps/sim/lib/workspace-files/__integration__/file-path-references.integration.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/
1111
import { generateId } from '@sim/utils/id'
1212
import { eq, inArray, sql } from 'drizzle-orm'
1313
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
14+
import { workspaceFileVfsPath } from '@/lib/uploads/contexts/workspace/file-manager'
1415
import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file'
1516
import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata'
1617
import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file'
@@ -69,6 +70,11 @@ describe('single-file operations addressed by VFS path', () => {
6970
foreignReport: '',
7071
chatUpload: '',
7172
archived: '',
73+
docsContent: '',
74+
docsOnly: '',
75+
spaced: '',
76+
alikeSpaced: '',
77+
alikeSingle: '',
7278
}
7379

7480
beforeAll(async () => {
@@ -125,6 +131,11 @@ describe('single-file operations addressed by VFS path', () => {
125131
})
126132
ids.chatUpload = await insertFile({ name: 'notes.txt', context: 'mothership' })
127133
ids.archived = await insertFile({ name: 'old.csv', deleted: true })
134+
ids.docsContent = await insertFile({ name: 'content', folderId: folders.docs })
135+
ids.docsOnly = await insertFile({ name: 'only-here.csv', folderId: folders.docs })
136+
ids.spaced = await insertFile({ name: 'my notes.csv' })
137+
ids.alikeSpaced = await insertFile({ name: 'a b.csv', folderId: folders.media })
138+
ids.alikeSingle = await insertFile({ name: 'a b.csv', folderId: folders.media })
128139
})
129140

130141
afterAll(async () => {
@@ -139,6 +150,33 @@ describe('single-file operations addressed by VFS path', () => {
139150
code: 'not_found',
140151
})
141152
await expect(describeFile('files/Docs')).rejects.toMatchObject({ code: 'not_found' })
153+
await expect(describeFile('files/only-here.csv')).rejects.toMatchObject({
154+
code: 'not_found',
155+
})
156+
})
157+
158+
it('treats a trailing `content` or `meta.json` as a name, never as the parent file', async () => {
159+
expect((await describeFile('files/Docs/content')).file.id).toBe(ids.docsContent)
160+
await expect(deleteFile('files/report.csv/content')).rejects.toMatchObject({
161+
code: 'not_found',
162+
})
163+
await expect(deleteFile('files/report.csv/meta.json')).rejects.toMatchObject({
164+
code: 'not_found',
165+
})
166+
expect((await row(ids.rootReport))?.deletedAt).toBeNull()
167+
})
168+
169+
it('resolves the path a listing prints for a name it displays normalized', async () => {
170+
const listed = workspaceFileVfsPath((await describeFile(ids.spaced)).file)
171+
expect((await describeFile(listed)).file.id).toBe(ids.spaced)
172+
})
173+
174+
it('refuses a path two display-alike names share instead of picking one', async () => {
175+
const listed = workspaceFileVfsPath((await describeFile(ids.alikeSingle)).file)
176+
await expect(deleteFile(listed)).rejects.toMatchObject({ code: 'validation' })
177+
for (const id of [ids.alikeSpaced, ids.alikeSingle]) {
178+
expect((await row(id))?.deletedAt).toBeNull()
179+
}
142180
})
143181

144182
it('renames and deletes only the file the path names', async () => {

0 commit comments

Comments
 (0)