230 lines
7.2 KiB
TypeScript
230 lines
7.2 KiB
TypeScript
import * as fsp from 'fs/promises';
|
|
import * as path from 'path';
|
|
|
|
export interface ZipArchiveEntry {
|
|
data: Buffer;
|
|
path: string;
|
|
}
|
|
|
|
interface CentralDirectoryEntry {
|
|
compressedSize: number;
|
|
crc: number;
|
|
data: Buffer;
|
|
localHeaderOffset: number;
|
|
name: Buffer;
|
|
uncompressedSize: number;
|
|
}
|
|
|
|
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
const ZIP_UTF8_FLAG = 0x0800;
|
|
const ZIP_STORE_METHOD = 0;
|
|
const ZIP_VERSION = 20;
|
|
const MAX_UINT32 = 0xffffffff;
|
|
|
|
const crcTable = buildCrcTable();
|
|
|
|
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
|
const localParts: Buffer[] = [];
|
|
const centralEntries: CentralDirectoryEntry[] = [];
|
|
let offset = 0;
|
|
|
|
for (const entry of entries) {
|
|
const normalizedPath = normalizeZipPath(entry.path);
|
|
const name = Buffer.from(normalizedPath, 'utf8');
|
|
const data = entry.data;
|
|
|
|
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
|
|
throw new Error('Data archive is too large for the portable ZIP format.');
|
|
}
|
|
|
|
const crc = crc32(data);
|
|
const localHeader = Buffer.alloc(30);
|
|
|
|
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
|
|
localHeader.writeUInt16LE(ZIP_VERSION, 4);
|
|
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
|
|
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
|
|
localHeader.writeUInt16LE(0, 10);
|
|
localHeader.writeUInt16LE(0, 12);
|
|
localHeader.writeUInt32LE(crc, 14);
|
|
localHeader.writeUInt32LE(data.length, 18);
|
|
localHeader.writeUInt32LE(data.length, 22);
|
|
localHeader.writeUInt16LE(name.length, 26);
|
|
localHeader.writeUInt16LE(0, 28);
|
|
|
|
localParts.push(localHeader, name, data);
|
|
centralEntries.push({
|
|
compressedSize: data.length,
|
|
crc,
|
|
data,
|
|
localHeaderOffset: offset,
|
|
name,
|
|
uncompressedSize: data.length
|
|
});
|
|
|
|
offset += localHeader.length + name.length + data.length;
|
|
}
|
|
|
|
const centralDirectoryOffset = offset;
|
|
const centralParts = centralEntries.map((entry) => {
|
|
const header = Buffer.alloc(46);
|
|
|
|
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
|
header.writeUInt16LE(ZIP_VERSION, 4);
|
|
header.writeUInt16LE(ZIP_VERSION, 6);
|
|
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
|
|
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
|
|
header.writeUInt16LE(0, 12);
|
|
header.writeUInt16LE(0, 14);
|
|
header.writeUInt32LE(entry.crc, 16);
|
|
header.writeUInt32LE(entry.compressedSize, 20);
|
|
header.writeUInt32LE(entry.uncompressedSize, 24);
|
|
header.writeUInt16LE(entry.name.length, 28);
|
|
header.writeUInt16LE(0, 30);
|
|
header.writeUInt16LE(0, 32);
|
|
header.writeUInt16LE(0, 34);
|
|
header.writeUInt16LE(0, 36);
|
|
header.writeUInt32LE(0, 38);
|
|
header.writeUInt32LE(entry.localHeaderOffset, 42);
|
|
|
|
offset += header.length + entry.name.length;
|
|
|
|
return Buffer.concat([header, entry.name]);
|
|
});
|
|
|
|
const centralDirectorySize = offset - centralDirectoryOffset;
|
|
|
|
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
|
|
throw new Error('Data archive is too large for the portable ZIP format.');
|
|
}
|
|
|
|
const end = Buffer.alloc(22);
|
|
|
|
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
|
end.writeUInt16LE(0, 4);
|
|
end.writeUInt16LE(0, 6);
|
|
end.writeUInt16LE(centralEntries.length, 8);
|
|
end.writeUInt16LE(centralEntries.length, 10);
|
|
end.writeUInt32LE(centralDirectorySize, 12);
|
|
end.writeUInt32LE(centralDirectoryOffset, 16);
|
|
end.writeUInt16LE(0, 20);
|
|
|
|
return Buffer.concat([...localParts, ...centralParts, end]);
|
|
}
|
|
|
|
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
|
const endOffset = findEndOfCentralDirectory(data);
|
|
|
|
if (endOffset < 0) {
|
|
throw new Error('The selected file is not a supported data archive.');
|
|
}
|
|
|
|
const entryCount = data.readUInt16LE(endOffset + 10);
|
|
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
|
const entries: ZipArchiveEntry[] = [];
|
|
let offset = centralDirectoryOffset;
|
|
|
|
for (let index = 0; index < entryCount; index += 1) {
|
|
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
throw new Error('The data archive directory is invalid.');
|
|
}
|
|
|
|
const method = data.readUInt16LE(offset + 10);
|
|
const compressedSize = data.readUInt32LE(offset + 20);
|
|
const uncompressedSize = data.readUInt32LE(offset + 24);
|
|
const nameLength = data.readUInt16LE(offset + 28);
|
|
const extraLength = data.readUInt16LE(offset + 30);
|
|
const commentLength = data.readUInt16LE(offset + 32);
|
|
const localHeaderOffset = data.readUInt32LE(offset + 42);
|
|
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
|
|
|
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
|
|
throw new Error('Compressed data archives are not supported by this build.');
|
|
}
|
|
|
|
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
|
throw new Error('The data archive contains an invalid file entry.');
|
|
}
|
|
|
|
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
|
|
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
|
|
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
|
|
|
entries.push({
|
|
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
|
|
path: entryPath
|
|
});
|
|
|
|
offset += 46 + nameLength + extraLength + commentLength;
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
|
|
const destinationRoot = path.resolve(destinationPath);
|
|
|
|
for (const entry of entries) {
|
|
const targetPath = path.resolve(destinationRoot, entry.path);
|
|
|
|
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
|
|
throw new Error('The data archive contains an unsafe path.');
|
|
}
|
|
|
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
|
await fsp.writeFile(targetPath, entry.data);
|
|
}
|
|
}
|
|
|
|
function findEndOfCentralDirectory(data: Buffer): number {
|
|
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
|
|
|
|
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
|
|
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
return offset;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function normalizeZipPath(value: string): string {
|
|
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
|
|
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
|
|
throw new Error('The data archive contains an unsafe path.');
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function buildCrcTable(): number[] {
|
|
const table: number[] = [];
|
|
|
|
for (let index = 0; index < 256; index += 1) {
|
|
let value = index;
|
|
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
value = (value & 1) !== 0
|
|
? 0xedb88320 ^ (value >>> 1)
|
|
: value >>> 1;
|
|
}
|
|
|
|
table[index] = value >>> 0;
|
|
}
|
|
|
|
return table;
|
|
}
|
|
|
|
function crc32(data: Buffer): number {
|
|
let crc = 0xffffffff;
|
|
|
|
for (const byte of data) {
|
|
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
}
|
|
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|