Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 26 additions & 33 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
downloadServableFileFromStorage,
} from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
assertActiveWorkspaceAccess,
Expand Down Expand Up @@ -175,37 +176,19 @@ const stripExtension = (name: string): string => {
/**
* Reduce an arbitrary name to a safe, flat file name: takes the final path
* segment, drops directory and traversal components, and falls back when the
* result would be empty or a dot segment. Used for zip entry names and the
* compress archive name so untrusted input cannot introduce nested or
* zip-slip-style paths.
* result would be empty or a dot segment. Used for the compress archive name so
* untrusted input cannot introduce nested or zip-slip-style paths.
*/
const toFlatFileName = (name: string, fallback: string): string => {
const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim()
if (!leaf || leaf === '.' || leaf === '..') return fallback
return leaf
}

/**
* Return a zip entry name unique within `usedNames`, appending a numeric suffix
* before the extension on collision (e.g., "data.csv" -> "data (1).csv").
*/
const uniqueZipEntryName = (name: string, usedNames: Set<string>): string => {
if (!usedNames.has(name)) {
usedNames.add(name)
return name
}

const dot = name.lastIndexOf('.')
const base = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
let counter = 1
let candidate = `${base} (${counter})${ext}`
while (usedNames.has(candidate)) {
counter += 1
candidate = `${base} (${counter})${ext}`
}
usedNames.add(candidate)
return candidate
/** A file bound for a compress archive, paired with the workspace folder it lives in. */
interface ArchiveEntry {
file: UserFile
folderPath: string | null
}

const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0)
Expand Down Expand Up @@ -678,17 +661,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

const userFiles: UserFile[] = workspaceFiles
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.concat(selectedInputFiles)
const workspaceEntries: ArchiveEntry[] = workspaceFiles.flatMap((file) => {
const userFile = workspaceFileToUserFile(file)
return userFile ? [{ file: userFile, folderPath: file?.folderPath ?? null }] : []
})

// Picker/upload values carry no workspace folder, so they archive at the root.
const archiveEntries = workspaceEntries.concat(
selectedInputFiles.map((file) => ({ file, folderPath: null }))
)
const userFiles: UserFile[] = archiveEntries.map((entry) => entry.file)

// Mirror the workspace folder layout, dropping the ancestor chain the whole
// selection shares so archiving one folder does not nest it under its parents.
const entryPaths = buildZipEntryPaths(
archiveEntries.map((entry) => ({ name: entry.file.name, folderPath: entry.folderPath })),
{ rebaseOnCommonFolder: true }
)

const zip = new JSZip()
const usedNames = new Set<string>()
let totalBytes = 0
for (const userFile of userFiles) {
for (const [index, userFile] of userFiles.entries()) {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied

Expand All @@ -707,7 +700,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 413 }
)
}
zip.file(uniqueZipEntryName(toFlatFileName(userFile.name, 'file'), usedNames), buffer)
zip.file(entryPaths[index], buffer)
}

const zipBuffer = await zip.generateAsync({
Expand Down
52 changes: 12 additions & 40 deletions apps/sim/app/api/workspaces/[id]/files/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,13 @@ import {
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'

const logger = createLogger('WorkspaceFilesDownloadAPI')
const MAX_ZIP_DOWNLOAD_FILES = 100
const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024

function safeZipPath(path: string): string {
return path
.split('/')
.map((segment) => {
const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_')
return cleaned === '.' || cleaned === '..' ? '_' : cleaned
})
.filter(Boolean)
.join('/')
}

function withZipPathSuffix(path: string, suffix: number): string {
const slashIndex = path.lastIndexOf('/')
const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : ''
const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path
const dotIndex = filename.lastIndexOf('.')

return dotIndex > 0
? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}`
: `${directory}${filename} (${suffix})`
}

function collectDescendantFolderIds(
selectedFolderIds: string[],
folders: Array<{ id: string; parentId: string | null }>
Expand Down Expand Up @@ -115,25 +94,18 @@ export const GET = withRouteHandler(

const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file)))

// Assemble zip synchronously so path deduplication is deterministic.
// Entry paths stay workspace-root-relative so a mixed selection of folders and
// loose files keeps the layout the user sees in the files list.
const entryPaths = buildZipEntryPaths(
filesToZip.map((file) => ({
name: file.name,
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
}))
)

const zip = new JSZip()
const usedPaths = new Set<string>()
for (let i = 0; i < filesToZip.length; i++) {
const file = filesToZip[i]
const buffer = buffers[i]
const folderPath = file.folderId ? folderPaths.get(file.folderId) : null
const basePath =
safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) ||
safeZipPath(file.name) ||
file.id
let zipPath = basePath
let suffix = 2
while (usedPaths.has(zipPath)) {
zipPath = withZipPathSuffix(basePath, suffix)
suffix++
}
usedPaths.add(zipPath)
zip.file(zipPath, buffer)
for (const [index, buffer] of buffers.entries()) {
zip.file(entryPaths[index], buffer)
}

const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
Expand Down
117 changes: 117 additions & 0 deletions apps/sim/lib/uploads/zip-entry-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path'

describe('buildZipEntryPaths', () => {
it('mirrors the workspace folder layout', () => {
expect(
buildZipEntryPaths([
{ name: '00_Executive-Overview.docx', folderPath: 'Motherland-Campaign' },
{ name: 'hero-key-art.png', folderPath: 'Motherland-Campaign/visuals' },
{ name: 'notes.md', folderPath: null },
])
).toEqual([
'Motherland-Campaign/00_Executive-Overview.docx',
'Motherland-Campaign/visuals/hero-key-art.png',
'notes.md',
])
})

it('keeps same-named files in different folders apart', () => {
expect(
buildZipEntryPaths([
{ name: 'README.docx', folderPath: 'Evidence/01-Access-Control' },
{ name: 'README.docx', folderPath: 'Evidence/02-Awareness-Training' },
])
).toEqual([
'Evidence/01-Access-Control/README.docx',
'Evidence/02-Awareness-Training/README.docx',
])
})

it('suffixes collisions before the extension without touching the directory', () => {
expect(
buildZipEntryPaths([
{ name: 'report.pdf', folderPath: 'docs' },
{ name: 'report.pdf', folderPath: 'docs' },
{ name: 'report.pdf', folderPath: 'docs' },
{ name: 'notes', folderPath: 'docs' },
{ name: 'notes', folderPath: 'docs' },
])
).toEqual([
'docs/report.pdf',
'docs/report (1).pdf',
'docs/report (2).pdf',
'docs/notes',
'docs/notes (1)',
])
})

it('drops the shared ancestor chain when rebasing', () => {
expect(
buildZipEntryPaths(
[
{ name: 'plan.docx', folderPath: 'Clients/Acme/Campaign' },
{ name: 'hero.png', folderPath: 'Clients/Acme/Campaign/visuals' },
],
{ rebaseOnCommonFolder: true }
)
).toEqual(['plan.docx', 'visuals/hero.png'])
})

it('compares the shared ancestor segment by segment, not by string prefix', () => {
expect(
buildZipEntryPaths(
[
{ name: 'a.txt', folderPath: 'Reports' },
{ name: 'b.txt', folderPath: 'Reports-2026' },
],
{ rebaseOnCommonFolder: true }
)
).toEqual(['Reports/a.txt', 'Reports-2026/b.txt'])
})

it('rebases to the archive root when every file shares one folder', () => {
expect(
buildZipEntryPaths(
[
{ name: 'a.txt', folderPath: 'Clients/Acme' },
{ name: 'b.txt', folderPath: 'Clients/Acme' },
],
{ rebaseOnCommonFolder: true }
)
).toEqual(['a.txt', 'b.txt'])
})

it('keeps full paths when a root-level file joins the selection', () => {
expect(
buildZipEntryPaths(
[
{ name: 'a.txt', folderPath: 'Clients/Acme' },
{ name: 'b.txt', folderPath: null },
],
{ rebaseOnCommonFolder: true }
)
).toEqual(['Clients/Acme/a.txt', 'b.txt'])
})

it('strips traversal and platform-illegal characters', () => {
expect(
buildZipEntryPaths([
{ name: '../../etc/passwd', folderPath: 'docs' },
{ name: 'C:\\Windows\\system.ini', folderPath: '../secrets' },
{ name: 'quarterly:report?.pdf', folderPath: 'q1/./q2' },
])
).toEqual(['docs/passwd', '_/secrets/system.ini', 'q1/_/q2/quarterly_report_.pdf'])
})

it('falls back to a usable name when nothing survives sanitization', () => {
expect(buildZipEntryPaths([{ name: '..', folderPath: null }])).toEqual(['file'])
})

it('returns no paths for an empty selection', () => {
expect(buildZipEntryPaths([], { rebaseOnCommonFolder: true })).toEqual([])
})
})
Loading
Loading