Files
makelore/electron/services/streaming-zip.ts

263 lines
8.4 KiB
TypeScript

import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { open } from 'node:fs/promises';
const ZIP_LOCAL_FILE_HEADER = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50;
const ZIP_DATA_DESCRIPTOR = 0x08074b50;
const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50;
const ZIP_VERSION = 20;
const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008;
const ZIP_STORE_METHOD = 0;
const ZIP_FILE_MODE = 0o100644;
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0);
export type StreamingZipEntry = {
path: string;
source: { filePath: string } | { bytes: Buffer };
beforeRead?: () => Promise<void>;
afterRead?: (result: { crc32: number; size: number }) => Promise<void>;
};
export type StreamingZipResult = {
archiveBytes: number;
sha256: string;
};
type CentralEntry = {
path: string;
crc32: number;
size: number;
offset: number;
};
type OutputState = {
error: Error | null;
};
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < table.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function updateCrc32(current: number, chunk: Uint8Array): number {
let value = current ^ 0xffffffff;
for (const byte of chunk) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
return (value ^ 0xffffffff) >>> 0;
}
function dosDateTime(date: Date): { time: number; day: number } {
const year = Math.max(1980, Math.min(2107, date.getFullYear()));
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
day: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
function pathBytes(path: string): Buffer {
const bytes = Buffer.from(path, 'utf8');
if (bytes.length > 0xffff) throw new Error('ZIP entry path is too long');
return bytes;
}
function localHeader(path: Buffer, date: Date): Buffer {
const header = Buffer.alloc(30 + path.length);
const dos = dosDateTime(date);
header.writeUInt32LE(ZIP_LOCAL_FILE_HEADER, 0);
header.writeUInt16LE(ZIP_VERSION, 4);
header.writeUInt16LE(ZIP_DATA_DESCRIPTOR_FLAG, 6);
header.writeUInt16LE(ZIP_STORE_METHOD, 8);
header.writeUInt16LE(dos.time, 10);
header.writeUInt16LE(dos.day, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(0, 18);
header.writeUInt32LE(0, 22);
header.writeUInt16LE(path.length, 26);
header.writeUInt16LE(0, 28);
path.copy(header, 30);
return header;
}
function dataDescriptor(crc32: number, size: number): Buffer {
const descriptor = Buffer.alloc(16);
descriptor.writeUInt32LE(ZIP_DATA_DESCRIPTOR, 0);
descriptor.writeUInt32LE(crc32 >>> 0, 4);
descriptor.writeUInt32LE(size >>> 0, 8);
descriptor.writeUInt32LE(size >>> 0, 12);
return descriptor;
}
function centralHeader(entry: CentralEntry, date: Date): Buffer {
const path = pathBytes(entry.path);
const header = Buffer.alloc(46 + path.length);
const dos = dosDateTime(date);
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_HEADER, 0);
header.writeUInt16LE(ZIP_VERSION, 4);
header.writeUInt16LE(ZIP_VERSION, 6);
header.writeUInt16LE(ZIP_DATA_DESCRIPTOR_FLAG, 8);
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
header.writeUInt16LE(dos.time, 12);
header.writeUInt16LE(dos.day, 14);
header.writeUInt32LE(entry.crc32 >>> 0, 16);
header.writeUInt32LE(entry.size >>> 0, 20);
header.writeUInt32LE(entry.size >>> 0, 24);
header.writeUInt16LE(path.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE((ZIP_FILE_MODE << 16) >>> 0, 38);
header.writeUInt32LE(entry.offset >>> 0, 42);
path.copy(header, 46);
return header;
}
function endOfCentralDirectory(count: number, size: number, offset: number): Buffer {
const footer = Buffer.alloc(22);
footer.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY, 0);
footer.writeUInt16LE(0, 4);
footer.writeUInt16LE(0, 6);
footer.writeUInt16LE(count, 8);
footer.writeUInt16LE(count, 10);
footer.writeUInt32LE(size, 12);
footer.writeUInt32LE(offset, 16);
footer.writeUInt16LE(0, 20);
return footer;
}
async function writeChunk(
output: ReturnType<typeof createWriteStream>,
hash: ReturnType<typeof createHash>,
chunk: Buffer,
state: OutputState,
): Promise<void> {
if (state.error) throw state.error;
hash.update(chunk);
if (output.write(chunk)) return;
await new Promise<void>((resolve, reject) => {
const onDrain = () => {
cleanup();
resolve();
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
output.removeListener('drain', onDrain);
output.removeListener('error', onError);
};
output.once('drain', onDrain);
output.once('error', onError);
});
if (state.error) throw state.error;
}
async function writeEntryData(
output: ReturnType<typeof createWriteStream>,
hash: ReturnType<typeof createHash>,
entry: StreamingZipEntry,
state: OutputState,
signal?: AbortSignal,
): Promise<{ crc32: number; size: number }> {
let crc32 = 0;
let size = 0;
const writeData = async (chunk: Buffer): Promise<void> => {
if (signal?.aborted) throw new Error('ZIP_WRITE_CANCELLED');
crc32 = updateCrc32(crc32, chunk);
size += chunk.length;
if (size > 0xffffffff) throw new Error('ZIP entry exceeds 4 GiB limit');
await writeChunk(output, hash, chunk, state);
};
if ('bytes' in entry.source) {
await entry.beforeRead?.();
await writeData(entry.source.bytes);
} else {
// Open the file before reading so a later path replacement cannot redirect
// the stream outside the package scan's validated inode. The explicit
// open also lets callers validate a stable-file race immediately before
// the descriptor is acquired.
const handle = await open(entry.source.filePath, 'r');
try {
await entry.beforeRead?.();
const input = handle.createReadStream({ autoClose: false });
for await (const chunk of input) {
await writeData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
} finally {
await handle.close();
}
}
await entry.afterRead?.({ crc32, size });
return { crc32, size };
}
/** Writes a deterministic, uncompressed ZIP without retaining file contents. */
export async function writeStoredZip(
entries: readonly StreamingZipEntry[],
archivePath: string,
options: { signal?: AbortSignal; date?: Date } = {},
): Promise<StreamingZipResult> {
if (entries.length > 0xffff) throw new Error('ZIP contains too many entries');
const date = options.date ?? FIXED_ZIP_TIME;
const output = createWriteStream(archivePath, { mode: 0o600 });
const outputState: OutputState = { error: null };
output.on('error', (error) => {
outputState.error = error;
});
const hash = createHash('sha256');
const central: CentralEntry[] = [];
let offset = 0;
try {
for (const entry of entries) {
const path = pathBytes(entry.path);
const start = offset;
const header = localHeader(path, date);
await writeChunk(output, hash, header, outputState);
offset += header.length;
const data = await writeEntryData(output, hash, entry, outputState, options.signal);
offset += data.size;
const descriptor = dataDescriptor(data.crc32, data.size);
await writeChunk(output, hash, descriptor, outputState);
offset += descriptor.length;
central.push({ path: entry.path, crc32: data.crc32, size: data.size, offset: start });
}
const centralOffset = offset;
for (const entry of central) {
const header = centralHeader(entry, date);
await writeChunk(output, hash, header, outputState);
offset += header.length;
}
const footer = endOfCentralDirectory(central.length, offset - centralOffset, centralOffset);
await writeChunk(output, hash, footer, outputState);
offset += footer.length;
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => output.removeListener('error', onError);
output.once('error', onError);
output.end(() => {
cleanup();
if (outputState.error) reject(outputState.error);
else resolve();
});
});
return { archiveBytes: offset, sha256: hash.digest('hex') };
} catch (error) {
output.destroy();
throw error;
}
}