plugins/sdvx@asphyxia/utils/zip.ts
dannylin0711 73b27b4e0d EG Final
2026-01-04 14:37:08 +08:00

185 lines
5.8 KiB
TypeScript

import fs from 'fs';
import path from 'path';
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buffer.length; i++) {
crc ^= buffer[i];
for (let j = 0; j < 8; j++) {
const mask = -(crc & 1);
crc = (crc >>> 1) ^ (0xedb88320 & mask);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function toDosTimeDate(date: Date): { time: number; date: number } {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
const dosTime = (hours << 11) | (minutes << 5) | seconds;
const dosDate = ((Math.max(year, 1980) - 1980) << 9) | (month << 5) | day;
return { time: dosTime & 0xffff, date: dosDate & 0xffff };
}
async function listFilesRecursive(rootDir: string): Promise<string[]> {
const results: string[] = [];
async function walk(currentDir: string) {
const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
results.push(fullPath);
}
}
}
await walk(rootDir);
return results;
}
function normalizeZipPath(p: string): string {
return p.replace(/\\/g, '/');
}
export async function zipFolderToFile(params: {
sourceDir: string;
outZipPath: string;
rootInZip?: string;
}): Promise<void> {
const sourceDir = path.resolve(params.sourceDir);
const outZipPath = path.resolve(params.outZipPath);
const rootInZip = params.rootInZip ? normalizeZipPath(params.rootInZip).replace(/^\/+|\/+$/g, '') : '';
await fs.promises.mkdir(path.dirname(outZipPath), { recursive: true });
const files = await listFilesRecursive(sourceDir);
const out = fs.createWriteStream(outZipPath);
let offset = 0;
type CentralEntry = {
fileName: string;
crc: number;
compressedSize: number;
uncompressedSize: number;
modTime: number;
modDate: number;
localHeaderOffset: number;
};
const central: CentralEntry[] = [];
const writeBuffer = async (buf: Buffer) => {
if (buf.length === 0) return;
await new Promise<void>((resolve, reject) => {
out.write(buf, (err) => (err ? reject(err) : resolve()));
});
offset += buf.length;
};
for (const filePath of files) {
const stat = await fs.promises.stat(filePath);
const data = await fs.promises.readFile(filePath);
const rel = normalizeZipPath(path.relative(sourceDir, filePath));
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
continue;
}
const fileName = rootInZip ? `${rootInZip}/${rel}` : rel;
const fileNameBytes = Buffer.from(fileName, 'utf8');
const crc = crc32(data);
const uncompressedSize = data.length;
const compressedSize = data.length;
const { time: modTime, date: modDate } = toDosTimeDate(stat.mtime);
const localHeaderOffset = offset;
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0); // Local file header signature
localHeader.writeUInt16LE(20, 4); // Version needed to extract
localHeader.writeUInt16LE(0, 6); // General purpose bit flag
localHeader.writeUInt16LE(0, 8); // Compression method (0 = store)
localHeader.writeUInt16LE(modTime, 10);
localHeader.writeUInt16LE(modDate, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(compressedSize, 18);
localHeader.writeUInt32LE(uncompressedSize, 22);
localHeader.writeUInt16LE(fileNameBytes.length, 26);
localHeader.writeUInt16LE(0, 28); // Extra field length
await writeBuffer(localHeader);
await writeBuffer(fileNameBytes);
await writeBuffer(data);
central.push({
fileName,
crc,
compressedSize,
uncompressedSize,
modTime,
modDate,
localHeaderOffset,
});
}
const centralDirOffset = offset;
for (const entry of central) {
const fileNameBytes = Buffer.from(entry.fileName, 'utf8');
const header = Buffer.alloc(46);
header.writeUInt32LE(0x02014b50, 0); // Central directory file header signature
header.writeUInt16LE(20, 4); // Version made by
header.writeUInt16LE(20, 6); // Version needed to extract
header.writeUInt16LE(0, 8); // General purpose bit flag
header.writeUInt16LE(0, 10); // Compression method
header.writeUInt16LE(entry.modTime, 12);
header.writeUInt16LE(entry.modDate, 14);
header.writeUInt32LE(entry.crc, 16);
header.writeUInt32LE(entry.compressedSize, 20);
header.writeUInt32LE(entry.uncompressedSize, 24);
header.writeUInt16LE(fileNameBytes.length, 28);
header.writeUInt16LE(0, 30); // Extra field length
header.writeUInt16LE(0, 32); // File comment length
header.writeUInt16LE(0, 34); // Disk number start
header.writeUInt16LE(0, 36); // Internal file attributes
header.writeUInt32LE(0, 38); // External file attributes
header.writeUInt32LE(entry.localHeaderOffset, 42);
await writeBuffer(header);
await writeBuffer(fileNameBytes);
}
const centralDirSize = offset - centralDirOffset;
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // End of central directory signature
eocd.writeUInt16LE(0, 4); // Number of this disk
eocd.writeUInt16LE(0, 6); // Disk where central directory starts
eocd.writeUInt16LE(central.length, 8); // Number of central directory records on this disk
eocd.writeUInt16LE(central.length, 10); // Total number of central directory records
eocd.writeUInt32LE(centralDirSize, 12);
eocd.writeUInt32LE(centralDirOffset, 16);
eocd.writeUInt16LE(0, 20); // ZIP file comment length
await writeBuffer(eocd);
await new Promise<void>((resolve, reject) => {
out.end(() => resolve());
out.on('error', reject);
});
}