mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(files): preserve slashes in folder paths (#6589)
* fix(files): preserve slashes in folder paths * fix(files): resolve escaped folder lookups
This commit is contained in:
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
)
|
||||
: '/'
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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<WorkspaceFileFolderApi[]>(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}/`)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<WorkspaceFileRecord | null> {
|
||||
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<WorkspaceFileRecord | null> {
|
||||
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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user