263 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
}
|