diff --git a/src/server/workspace-upload-store.ts b/src/server/workspace-upload-store.ts index c185b084..2b7806d5 100644 --- a/src/server/workspace-upload-store.ts +++ b/src/server/workspace-upload-store.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { readdirSync, renameSync, rmdirSync, unlinkSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, open, readFile } from 'node:fs/promises' import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { HttpError } from './http-errors.js' import type { Database } from './sqlite.js' @@ -283,14 +283,22 @@ export const createWorkspaceUploadStore = (db: Database, uploadsDir: string) => created_at: now, } + let ownsFile = false try { await mkdir(dirname(storagePath), { recursive: true }) // Async IO keeps a 100MB write from stalling every PTY/WebSocket on // the event loop; `wx` keeps the no-clobber guarantee. The INSERT's // WHERE EXISTS guard plus the removeFile cleanup below still cover a // workspace deleted while the write was in flight. - await writeFile(storagePath, input.data, { flag: 'wx' }) + const file = await open(storagePath, 'wx') + ownsFile = true + try { + await file.writeFile(input.data) + } finally { + await file.close() + } } catch { + if (ownsFile) removeFile(storagePath) throw new HttpError(500, 'Upload could not be saved') } diff --git a/tests/helpers/upload-partial-write.ts b/tests/helpers/upload-partial-write.ts new file mode 100644 index 00000000..e328301f --- /dev/null +++ b/tests/helpers/upload-partial-write.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { openRuntimeDatabase } from '../../src/server/runtime-database.js' +import { createWorkspaceUploadStore } from '../../src/server/workspace-upload-store.js' + +process.on('SIGXFSZ', () => {}) +const root = process.argv[2] +assert(root) +const limit = readFileSync('/proc/self/limits', 'utf8') + .split('\n') + .find((line) => line.startsWith('Max file size')) +assert.match(limit ?? '', /65536\s+65536/) +const db = openRuntimeDatabase() +db.prepare('INSERT INTO workspaces(id,name,path,created_at) VALUES(?,?,?,?)').run( + 'fixture', + 'Fixture', + root, + 0 +) +const store = createWorkspaceUploadStore(db, join(root, 'uploads')) +try { + const small = await store.saveUpload({ + workspaceId: 'fixture', + originalName: 'small.bin', + data: Buffer.from('success'), + }) + const dir = join(root, 'uploads', 'fixture') + const before = readdirSync(dir) + await assert.rejects( + writeFile(join(root, 'native.bin'), Buffer.alloc(1024 * 1024), { flag: 'wx' }), + { code: 'EFBIG' } + ) + assert.equal(statSync(join(root, 'native.bin')).size, 65536) + await assert.rejects( + store.saveUpload({ + workspaceId: 'fixture', + originalName: 'large.bin', + data: Buffer.alloc(1024 * 1024), + }), + { statusCode: 500, message: 'Upload could not be saved' } + ) + assert.equal(store.listUploads('fixture').length, 1) + assert.equal((await store.readUpload('fixture', small.id))?.data.toString(), 'success') + db.close() + const after = readdirSync(dir) + console.log( + JSON.stringify({ + nativeError: 'EFBIG', + nativePartialBytes: 65536, + mappedStoreStatus: 500, + rows: 1, + before, + after, + databaseClosed: !db.isOpen, + }) + ) + assert.deepEqual(after, before, 'failed upload must leave no unrecorded file after close') +} finally { + if (db.isOpen) db.close() +} diff --git a/tests/unit/workspace-upload-write.test.ts b/tests/unit/workspace-upload-write.test.ts new file mode 100644 index 00000000..c08c34e4 --- /dev/null +++ b/tests/unit/workspace-upload-write.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, test, vi } from 'vitest' +import { openRuntimeDatabase } from '../../src/server/runtime-database.js' +import { createWorkspaceUploadStore } from '../../src/server/workspace-upload-store.js' +import { removeTestPath } from '../helpers/fs-cleanup.js' + +// Deterministic ID injection only for collision coverage; native child imports are unmocked. +vi.mock('node:crypto', async (original) => ({ + ...(await original()), + randomUUID: () => '11111111-1111-4111-8111-111111111111', +})) +const dirs: string[] = [] +const fixture = () => { + const dir = mkdtempSync(join(tmpdir(), 'hive-upload-write-')) + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) removeTestPath(dir) +}) + +test.skipIf(process.platform !== 'linux')( + 'native partial write removes only its created upload', + (ctx) => { + const available = spawnSync('prlimit', ['--version'], { timeout: 5_000 }) + if ((available.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + ctx.skip() + return + } + expect(available.error).toBeUndefined() + expect(available.status).toBe(0) + const result = spawnSync( + 'prlimit', + [ + '--fsize=65536:65536', + '--', + process.execPath, + '--import', + 'tsx', + 'tests/helpers/upload-partial-write.ts', + fixture(), + ], + { + timeout: 15_000, + env: { ...process.env, TSX_DISABLE_CACHE: '1' }, + encoding: 'utf8', + } + ) + expect(result.error).toBeUndefined() + expect(result.status, result.stdout + result.stderr).toBe(0) + }, + 20_000 +) + +test('injected ID collision preserves existing file and inserts no metadata', async () => { + const root = fixture() + const db = openRuntimeDatabase() + try { + db.prepare('INSERT INTO workspaces(id,name,path,created_at) VALUES(?,?,?,?)').run( + 'fixture', + 'Fixture', + root, + 0 + ) + const store = createWorkspaceUploadStore(db, root) + const dir = join(root, 'fixture') + mkdirSync(dir) + const path = join(dir, '11111111-1111-4111-8111-111111111111.bin') + writeFileSync(path, 'existing bytes') + await expect( + store.saveUpload({ + workspaceId: 'fixture', + originalName: 'collision.bin', + data: Buffer.from('replacement'), + }) + ).rejects.toMatchObject({ statusCode: 500, message: 'Upload could not be saved' }) + expect(readFileSync(path, 'utf8')).toBe('existing bytes') + expect(readdirSync(dir)).toEqual(['11111111-1111-4111-8111-111111111111.bin']) + expect(store.listUploads('fixture')).toEqual([]) + } finally { + db.close() + } +})