Skip to content

Commit 422d4ac

Browse files
committed
fix(zip-uploads): address review follow-ups on the extract path
- Roll back already-uploaded files when an upload fails mid-extraction (storage/DB error, quota crossed), so callers and retries never observe a partial tree - Fold reserved system folder names (.changelogs, .plans) into the 'archive' fallback so extraction can't write into alias-backing namespaces or bypass the already-extracted lookup that hides them - Align the archive byte-sniff budget with the read path's inline text cap (5MB), so any mislabeled '.zip' small enough to be read inline is sniffed and read instead of dead-ending
1 parent d0eb9e5 commit 422d4ac

7 files changed

Lines changed: 120 additions & 34 deletions

File tree

apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,32 @@ describe('executeMaterializeFile - extract operation', () => {
429429
expect(mockDecompress).toHaveBeenCalledTimes(1)
430430
})
431431

432+
it('folds reserved system folder names into the "archive" fallback folder', async () => {
433+
// '.changelogs' / '.plans' back workflow changelog/plan aliases; extraction
434+
// must never write into them (and the already-extracted lookup hides them,
435+
// so a second extract would silently duplicate).
436+
mockFindUpload.mockResolvedValue(
437+
zipRow({ displayName: '.changelogs.zip', originalName: '.changelogs.zip' })
438+
)
439+
mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes'))
440+
mockDecompress.mockResolvedValue({
441+
extracted: [{ id: 'f1', name: 'a.txt', url: '/x', size: 1, type: 'text/plain', key: 'k1' }],
442+
skipped: 0,
443+
skippedUnsafePaths: [],
444+
})
445+
446+
const result = await executeMaterializeFile(
447+
{ fileNames: ['.changelogs.zip'], operation: 'extract' },
448+
context
449+
)
450+
451+
expect(result.success).toBe(true)
452+
expect(mockDecompress).toHaveBeenCalledWith(
453+
expect.any(Buffer),
454+
expect.objectContaining({ rootFolderSegments: ['archive'] })
455+
)
456+
})
457+
432458
it('folds degenerate archive names into the "archive" fallback folder', async () => {
433459
mockFindUpload.mockResolvedValue(zipRow({ displayName: '..zip', originalName: '..zip' }))
434460
mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes'))

apps/sim/lib/copilot/tools/handlers/materialize-file.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
1515
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader'
1616
import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
17+
import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases'
1718
import { getServePathPrefix } from '@/lib/uploads'
1819
import {
1920
ArchiveError,
@@ -303,6 +304,9 @@ async function executeImport(
303304
* empty), so a hostile upload name like `..zip` or `\x01.zip` lands in the
304305
* `archive` fallback instead of surfacing a raw internal error — and so the
305306
* VFS-encoded destination path can be computed before anything is extracted.
307+
* Reserved system backing folders (`.changelogs`, `.plans`) also fall back:
308+
* extraction must never write into — or hide behind — those namespaces (the
309+
* already-extracted lookup skips them, so they'd also duplicate silently).
306310
*/
307311
function archiveFolderBaseName(displayName: string): string {
308312
const stripped = displayName
@@ -312,7 +316,15 @@ function archiveFolderBaseName(displayName: string): string {
312316
.replace(/[\x00-\x1f\x7f]/g, '')
313317
.replace(/[/\\]/g, '-')
314318
.trim()
315-
return !stripped || stripped === '.' || stripped === '..' ? 'archive' : stripped
319+
if (
320+
!stripped ||
321+
stripped === '.' ||
322+
stripped === '..' ||
323+
isReservedWorkflowAliasBackingDisplayPath(stripped)
324+
) {
325+
return 'archive'
326+
}
327+
return stripped
316328
}
317329

318330
/**

apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const { mockReadFileRecord, mockFetchBuffer } = vi.hoisted(() => ({
1414

1515
vi.mock('@/lib/copilot/vfs/file-reader', () => ({
1616
readFileRecord: mockReadFileRecord,
17+
MAX_TEXT_READ_BYTES: 5 * 1024 * 1024,
1718
}))
1819

1920
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({

apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { workspaceFiles } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
55
import { and, asc, desc, eq, isNull, or } from 'drizzle-orm'
6-
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
6+
import {
7+
type FileReadResult,
8+
MAX_TEXT_READ_BYTES,
9+
readFileRecord,
10+
} from '@/lib/copilot/vfs/file-reader'
711
import {
812
type GrepCountEntry,
913
type GrepMatch,
@@ -27,9 +31,11 @@ const logger = createLogger('UploadFileReader')
2731
* bytes decide (a mislabeled text file named `data.zip` stays readable instead
2832
* of being trapped between read-says-extract and extract-says-invalid); above
2933
* it the extension is trusted so a real 100MB zip is never downloaded just to
30-
* refuse it.
34+
* refuse it. Aligned with the read path's inline text cap — any mislabeled
35+
* file too big to sniff would be rejected by read() as too large anyway, so
36+
* nothing readable is ever dead-ended.
3137
*/
32-
const ARCHIVE_SNIFF_MAX_BYTES = 1024 * 1024
38+
const ARCHIVE_SNIFF_MAX_BYTES = MAX_TEXT_READ_BYTES
3339

3440
/**
3541
* True when the upload should get extract-first guidance: named like an archive

apps/sim/lib/copilot/vfs/file-reader.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ function recordSpanError(span: Span, err: unknown) {
2626

2727
const logger = createLogger('FileReader')
2828

29-
const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
29+
/** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */
30+
export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
3031
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
3132
// Parseable-document byte cap. Large office/PDF files can still
3233
// produce huge extracted text; reject up front to avoid wasting a

apps/sim/lib/uploads/archive.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import { Buffer } from 'buffer'
55
import JSZip from 'jszip'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockEnsureFolder, mockUpload } = vi.hoisted(() => ({
8+
const { mockEnsureFolder, mockUpload, mockDelete } = vi.hoisted(() => ({
99
mockEnsureFolder: vi.fn(),
1010
mockUpload: vi.fn(),
11+
mockDelete: vi.fn(),
1112
}))
1213
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
1314
ensureWorkspaceFileFolderPath: mockEnsureFolder,
1415
}))
1516
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
1617
uploadWorkspaceFile: mockUpload,
18+
deleteWorkspaceFile: mockDelete,
1719
}))
1820

1921
import {
@@ -70,6 +72,7 @@ function craftCentralDirectory(records: number, extraPerRecord: number): Buffer
7072
beforeEach(() => {
7173
vi.clearAllMocks()
7274
mockEnsureFolder.mockResolvedValue('folder_1')
75+
mockDelete.mockResolvedValue(undefined)
7376
mockUpload.mockImplementation(async (_ws: string, _uid: string, buf: Buffer, name: string) => ({
7477
id: `f_${name}`,
7578
name,
@@ -184,6 +187,25 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => {
184187
expect(mockUpload).not.toHaveBeenCalled()
185188
})
186189

190+
it('rolls back already-uploaded files when an upload fails mid-extraction', async () => {
191+
// Pass 1 validates caps, but an upload itself can still fail mid-loop
192+
// (storage/DB error, quota crossed). Every file written before the failure
193+
// must be deleted so callers and retries never observe a partial tree.
194+
const buffer = await buildZip({ 'a.txt': 'first', 'b.txt': 'second', 'c.txt': 'third' })
195+
mockUpload
196+
.mockResolvedValueOnce({ id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 })
197+
.mockResolvedValueOnce({ id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 })
198+
.mockRejectedValueOnce(new Error('storage quota exceeded'))
199+
200+
await expect(
201+
decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' })
202+
).rejects.toThrow('storage quota exceeded')
203+
204+
expect(mockDelete).toHaveBeenCalledTimes(2)
205+
expect(mockDelete).toHaveBeenCalledWith('ws', 'f_a')
206+
expect(mockDelete).toHaveBeenCalledWith('ws', 'f_b')
207+
})
208+
187209
it('does not count noise entries toward the extraction cap when they are being skipped', async () => {
188210
// macOS Finder zips carry a __MACOSX/._* shadow per file, doubling the raw
189211
// entry count. 501 files + 501 shadows = 1002 raw entries — over the

apps/sim/lib/uploads/archive.ts

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import type { Readable } from 'stream'
33
import JSZip from 'jszip'
44
import { readZipCentralDirectoryStats } from '@/lib/file-parsers/zip-guard'
55
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
6-
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
6+
import {
7+
deleteWorkspaceFile,
8+
uploadWorkspaceFile,
9+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
710
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
811
import type { UserFile } from '@/executor/types'
912

@@ -328,40 +331,55 @@ export async function decompressArchiveBufferToWorkspaceFiles(
328331
}
329332

330333
// Pass 2 — extract: the archive is proven within caps; inflate again and upload.
334+
// Uploads themselves can still fail mid-loop (storage/DB errors, quota crossed
335+
// by another writer), so a failure rolls back every file written so far —
336+
// callers and their retries must never observe a partial tree.
331337
const folderIdCache = new Map<string, string | null>()
332338
const extracted: UserFile[] = []
333339
let totalBytes = 0
334-
for (const { entry, segments } of safeEntries) {
335-
const result = await inflateEntryWithinCaps(entry, MAX_ARCHIVE_TOTAL_BYTES - totalBytes, true)
336-
if (!result.ok) throwInflateCapError(result.reason, entry.name)
337-
totalBytes += result.size
338-
const entryBuffer = result.buffer as Buffer
339-
340-
const leafName = segments[segments.length - 1]
341-
const folderSegments = [...rootFolderSegments, ...segments.slice(0, -1)]
342-
const folderKey = folderSegments.join('/')
343-
let folderId = folderIdCache.get(folderKey)
344-
if (folderId === undefined) {
345-
folderId = await ensureWorkspaceFileFolderPath({
340+
try {
341+
for (const { entry, segments } of safeEntries) {
342+
const result = await inflateEntryWithinCaps(entry, MAX_ARCHIVE_TOTAL_BYTES - totalBytes, true)
343+
if (!result.ok) throwInflateCapError(result.reason, entry.name)
344+
totalBytes += result.size
345+
const entryBuffer = result.buffer as Buffer
346+
347+
const leafName = segments[segments.length - 1]
348+
const folderSegments = [...rootFolderSegments, ...segments.slice(0, -1)]
349+
const folderKey = folderSegments.join('/')
350+
let folderId = folderIdCache.get(folderKey)
351+
if (folderId === undefined) {
352+
folderId = await ensureWorkspaceFileFolderPath({
353+
workspaceId,
354+
userId,
355+
pathSegments: folderSegments,
356+
})
357+
folderIdCache.set(folderKey, folderId)
358+
}
359+
360+
const mimeType = getMimeTypeFromExtension(getFileExtension(leafName))
361+
const uploaded = await uploadWorkspaceFile(
346362
workspaceId,
347363
userId,
348-
pathSegments: folderSegments,
349-
})
350-
folderIdCache.set(folderKey, folderId)
364+
entryBuffer,
365+
leafName,
366+
mimeType,
367+
{
368+
folderId,
369+
}
370+
)
371+
extracted.push(uploaded)
351372
}
352-
353-
const mimeType = getMimeTypeFromExtension(getFileExtension(leafName))
354-
const uploaded = await uploadWorkspaceFile(
355-
workspaceId,
356-
userId,
357-
entryBuffer,
358-
leafName,
359-
mimeType,
360-
{
361-
folderId,
373+
} catch (error) {
374+
for (const file of extracted) {
375+
try {
376+
await deleteWorkspaceFile(workspaceId, file.id)
377+
} catch {
378+
// Best-effort: a file whose cleanup fails is still soft-deletable by hand;
379+
// the original error is what the caller needs to see.
362380
}
363-
)
364-
extracted.push(uploaded)
381+
}
382+
throw error
365383
}
366384

367385
return { extracted, skipped, skippedUnsafePaths }

0 commit comments

Comments
 (0)