diff --git a/backend/src/__tests__/filesystem-compose-save-publication.test.ts b/backend/src/__tests__/filesystem-compose-save-publication.test.ts index 240f2379..949917d4 100644 --- a/backend/src/__tests__/filesystem-compose-save-publication.test.ts +++ b/backend/src/__tests__/filesystem-compose-save-publication.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { promises as fsp } from 'fs'; import fsSync from 'fs'; import path from 'path'; +import { Readable } from 'stream'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; let tmpDir: string; @@ -212,6 +213,24 @@ describe('compose save publication', () => { expect(fsSync.readFileSync(victim, 'utf8')).toBe('untouched'); }); + it('releases the upload source when the staging file cannot be created', async () => { + const source = Readable.from([Buffer.from(composeContent(['web']))]); + const realOpen = fsp.open.bind(fsp); + vi.spyOn(fsp, 'open').mockImplementation((async (...args: Parameters) => { + if (String(args[0]).includes('sencho-tmp')) throw Object.assign(new Error('refused'), { code: 'EACCES' }); + return realOpen(...args); + }) as typeof fsp.open); + const writer = FileSystemService.getInstance() as unknown as { + writeStackFileAtomic(target: string, data: Readable): Promise; + }; + + await expect(writer.writeStackFileAtomic(path.join(stackDir, 'compose.yaml'), source)) + .rejects.toThrow('refused'); + + expect(source.destroyed).toBe(true); + expect(stagingFiles()).toEqual([]); + }); + it('removes the staging file when restricted content fails to publish', async () => { const target = path.join(stackDir, 'compose.yaml'); fsSync.chmodSync(target, 0o600); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 0bf771ce..85a7244d 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -2479,6 +2479,11 @@ export class FileSystemService { if (staged) await recordAtomicPublication(safePath, staged); } } finally { + // A source stream holds its own descriptor from the moment it is + // created, so a failure before it is consumed (the staging open + // refused) would otherwise leave it open. Destroying a finished stream + // is a no-op. + if (data instanceof Readable) data.destroy(); if (stagedTmp) { await fsPromises.unlink(tmpPath).catch(() => {}); }