Skip to content
Open
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
12 changes: 10 additions & 2 deletions src/server/workspace-upload-store.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
}

Expand Down
62 changes: 62 additions & 0 deletions tests/helpers/upload-partial-write.ts
Original file line number Diff line number Diff line change
@@ -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()
}
86 changes: 86 additions & 0 deletions tests/unit/workspace-upload-write.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:crypto')>()),
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()
}
})