fix(filesystem): destroy the upload source when the staged write fails early

A source stream opens its descriptor when it is created, so a refused
staging open left it open until exit. Destroy it on the way out of the
atomic writer; destroying a finished stream is a no-op.
This commit is contained in:
Anso
2026-10-24 20:44:26 -04:00
parent 7d68187c04
commit 0bd52d009e
2 changed files with 24 additions and 0 deletions
@@ -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<typeof fsp.open>) => {
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<void>;
};
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);
@@ -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(() => {});
}