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:
Theodore Li
2026-08-11 23:13:18 -04:00
committed by GitHub
parent 766526b22e
commit f306b517c1
23 changed files with 257 additions and 62 deletions
@@ -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' }),
+9 -2
View File
@@ -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,
+14
View File
@@ -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 -1
View File
@@ -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', () => {
+4 -1
View File
@@ -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}`
}
+6 -3
View File
@@ -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(
+11
View File
@@ -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([
+8 -7
View File
@@ -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
}