mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user