fix(server): handle stream errors when patching zip archives

Add an error event listener to the source stream when appending entries during zip patching to prevent unhandled ENOENT errors from crashing the Node process.

Closes #226
This commit is contained in:
neon
2026-06-05 18:21:28 +00:00
parent 84ae6b19ca
commit 04a4fdd0eb
4 changed files with 76 additions and 8 deletions
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import { PassThrough, Readable } from 'stream';
import type { MockedFunction } from 'vitest';
import { createWriteStream } from 'fs';
import * as fs from 'fs/promises';
@@ -70,6 +71,7 @@ const mockRename = fs.rename as MockedFunction<typeof fs.rename>;
const mockUnlink = fs.unlink as MockedFunction<typeof fs.unlink>;
const mockRandomUuid = randomUUID as MockedFunction<typeof randomUUID>;
const mockOpenFile = unzipper.Open.file as MockedFunction<typeof unzipper.Open.file>;
const streamFrom = (value: string) => Readable.from([Buffer.from(value)]);
describe('cbz-zip-patcher', () => {
beforeEach(() => {
@@ -97,11 +99,11 @@ describe('cbz-zip-patcher', () => {
it('writeComicInfoToZip preserves other entries and writes xml to existing comic info path', async () => {
const existingEntry = {
path: 'metadata/ComicInfo.xml',
stream: vi.fn(() => Buffer.from('oldxml')),
stream: vi.fn(() => streamFrom('oldxml')),
};
const imageEntry = {
path: 'pages/001.jpg',
stream: vi.fn(() => Buffer.from('img')),
stream: vi.fn(() => streamFrom('img')),
};
mockOpenFile.mockResolvedValue({ files: [existingEntry, imageEntry] } as never);
@@ -109,13 +111,32 @@ describe('cbz-zip-patcher', () => {
await writeComicInfoToZip('/books/a.cbz', '<ComicInfo><Title>New</Title></ComicInfo>');
expect(archiver).toHaveBeenCalledWith('zip', { zlib: { level: 6 } });
expect(lastArchive.append).toHaveBeenCalledWith(imageEntry.stream(), { name: 'pages/001.jpg' });
expect(imageEntry.stream).toHaveBeenCalledTimes(1);
expect(lastArchive.append).toHaveBeenCalledWith(expect.objectContaining({ pipe: expect.any(Function) }), { name: 'pages/001.jpg' });
expect(lastArchive.append).toHaveBeenCalledWith(Buffer.from('<ComicInfo><Title>New</Title></ComicInfo>', 'utf-8'), {
name: 'metadata/ComicInfo.xml',
});
expect(mockRename).toHaveBeenCalledWith('/books/.cbx-write-uuid-1234', '/books/a.cbz');
});
it('rejects when a source entry stream errors while patching', async () => {
const sourceError = Object.assign(new Error('ENOENT: no such file or directory, open /books/a.cbz'), { code: 'ENOENT' });
const imageEntry = {
path: 'pages/001.jpg',
stream: vi.fn(() => {
const source = new PassThrough();
process.nextTick(() => source.emit('error', sourceError));
return source;
}),
};
mockOpenFile.mockResolvedValue({ files: [imageEntry] } as never);
await expect(writeComicInfoToZip('/books/a.cbz', '<ComicInfo/>')).rejects.toThrow('ENOENT');
expect(imageEntry.stream).toHaveBeenCalledTimes(1);
expect(mockRename).not.toHaveBeenCalled();
});
it('deletes temp archive when rename fails', async () => {
mockOpenFile.mockResolvedValue({ files: [] } as never);
mockRename.mockRejectedValue(new Error('cross-device link'));
@@ -1,6 +1,7 @@
import { createWriteStream } from 'fs';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
import type { Readable } from 'stream';
import * as unzipper from 'unzipper';
import archiver from 'archiver';
import { replaceFileAtomically } from '../shared/atomic-file-replace';
@@ -34,7 +35,7 @@ export async function writeComicInfoToZip(filePath: string, xmlContent: string):
for (const entry of zip.files) {
if (isComicInfoEntry(entry.path)) continue;
archive.append(entry.stream(), { name: entry.path });
appendEntryStream(archive, entry, reject);
}
archive.append(Buffer.from(xmlContent, 'utf-8'), { name: xmlEntryPath });
@@ -43,3 +44,13 @@ export async function writeComicInfoToZip(filePath: string, xmlContent: string):
await replaceFileAtomically(tmpPath, filePath);
}
function appendEntryStream(
archive: ReturnType<typeof archiver>,
entry: { path: string; stream: () => Readable },
reject: (error: Error) => void,
): void {
const source = entry.stream();
source.once('error', reject);
archive.append(source, { name: entry.path });
}
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import { PassThrough, Readable } from 'stream';
import type { MockedFunction } from 'vitest';
import { createWriteStream } from 'fs';
import * as fs from 'fs/promises';
@@ -60,6 +61,7 @@ const mockCreateWriteStream = createWriteStream as MockedFunction<typeof createW
const mockRename = fs.rename as MockedFunction<typeof fs.rename>;
const mockUnlink = fs.unlink as MockedFunction<typeof fs.unlink>;
const mockOpenFile = unzipper.Open.file as MockedFunction<typeof unzipper.Open.file>;
const streamFrom = (value: string) => Readable.from([Buffer.from(value)]);
describe('epub-zip-patcher', () => {
beforeEach(() => {
@@ -85,8 +87,8 @@ describe('epub-zip-patcher', () => {
it('patch writes mimetype first, patches existing entries, and appends new ones', async () => {
const opfEntry = { path: 'OPS/content.opf', stream: vi.fn(() => Buffer.from('old-opf')) };
const chapterEntry = { path: 'OPS/ch1.xhtml', stream: vi.fn(() => Buffer.from('old-ch1')) };
const mimetypeEntry = { path: 'mimetype', stream: vi.fn(() => Buffer.from('old-mimetype')) };
const chapterEntry = { path: 'OPS/ch1.xhtml', stream: vi.fn(() => streamFrom('old-ch1')) };
const mimetypeEntry = { path: 'mimetype', stream: vi.fn(() => streamFrom('old-mimetype')) };
mockOpenFile.mockResolvedValue({ files: [mimetypeEntry, opfEntry, chapterEntry] } as never);
@@ -100,11 +102,30 @@ describe('epub-zip-patcher', () => {
expect(archiver).toHaveBeenCalledWith('zip', { zlib: { level: 6 } });
expect(lastArchive.append).toHaveBeenNthCalledWith(1, Buffer.from('application/epub+zip'), { name: 'mimetype', store: true });
expect(lastArchive.append).toHaveBeenCalledWith(Buffer.from('new-opf'), { name: 'OPS/content.opf' });
expect(lastArchive.append).toHaveBeenCalledWith(chapterEntry.stream(), { name: 'OPS/ch1.xhtml' });
expect(chapterEntry.stream).toHaveBeenCalledTimes(1);
expect(lastArchive.append).toHaveBeenCalledWith(expect.objectContaining({ pipe: expect.any(Function) }), { name: 'OPS/ch1.xhtml' });
expect(lastArchive.append).toHaveBeenCalledWith(Buffer.from('new-cover'), { name: 'OPS/images/cover.jpg' });
expect(mockRename).toHaveBeenCalledWith('/book.epub.tmp', '/book.epub');
});
it('rejects when a source entry stream errors while patching', async () => {
const sourceError = Object.assign(new Error('ENOENT: no such file or directory, open /book.epub'), { code: 'ENOENT' });
const missingEntry = {
path: 'OPS/ch1.xhtml',
stream: vi.fn(() => {
const source = new PassThrough();
process.nextTick(() => source.emit('error', sourceError));
return source;
}),
};
mockOpenFile.mockResolvedValue({ files: [missingEntry] } as never);
await expect(patch('/book.epub', new Map())).rejects.toThrow('ENOENT');
expect(missingEntry.stream).toHaveBeenCalledTimes(1);
expect(mockRename).not.toHaveBeenCalled();
});
it('deletes temp file when rename fails', async () => {
mockOpenFile.mockResolvedValue({ files: [] } as never);
mockRename.mockRejectedValue(new Error('rename failed'));
@@ -1,4 +1,5 @@
import { createWriteStream } from 'fs';
import type { Readable } from 'stream';
import * as unzipper from 'unzipper';
import archiver from 'archiver';
import { replaceFileAtomically } from '../shared/atomic-file-replace';
@@ -27,7 +28,11 @@ export async function patch(filePath: string, patches: Map<string, Buffer>): Pro
for (const entry of zip.files) {
if (entry.path === 'mimetype') continue;
const patched = patches.get(entry.path);
archive.append(patched ?? entry.stream(), { name: entry.path });
if (patched) {
archive.append(patched, { name: entry.path });
} else {
appendEntryStream(archive, entry, reject);
}
}
for (const [path, content] of patches) {
@@ -41,3 +46,13 @@ export async function patch(filePath: string, patches: Map<string, Buffer>): Pro
await replaceFileAtomically(tmpPath, filePath);
}
function appendEntryStream(
archive: ReturnType<typeof archiver>,
entry: { path: string; stream: () => Readable },
reject: (error: Error) => void,
): void {
const source = entry.stream();
source.once('error', reject);
archive.append(source, { name: entry.path });
}