Files
Toju/electron/data-archive.ts
2026-04-27 03:29:41 +02:00

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;
}