src / zip.ts
src / zip.ts
import * as fs from "fs";
import * as path from "path";
import * as zlib from "zlib";
import { ZipCreateResult, ZipExtractResult, ZipListResult } from "./types";
import { expandPath } from "./securityEnhanced";
// ── Zip Operations (pure JS, no external deps) ──
interface ZipEntry {
name: string;
data: Buffer;
uncompressedSize: number;
compressedSize: number;
isDirectory: boolean;
}
export function crc32(buf: Buffer): number {
let crc = 0xFFFFFFFF;
const table: number[] = [];
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
for (let i = 0; i < buf.length; i++) {
crc = table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
export function writeUint32LE(buf: Buffer, offset: number, value: number): void {
if (value < 0 || value > 0xFFFFFFFF) throw new RangeError(`writeUint32LE: value ${value} out of range`);
buf.writeUInt32LE(value, offset);
}
export function writeUint16LE(buf: Buffer, offset: number, value: number): void {
if (value < 0 || value > 0xFFFF) throw new RangeError(`writeUint16LE: value ${value} out of range`);
buf.writeUInt16LE(value, offset);
}
/**
* Build a valid PKZIP archive.
*/
export function buildZip(entries: ZipEntry[]): Buffer {
const parts: Buffer[] = [];
const localOffsets: number[] = [];
for (const entry of entries) {
const localOffset = parts.reduce((sum, b) => sum + b.length, 0);
localOffsets.push(localOffset);
let compressed: Buffer;
let compressionMethod = 0;
if (entry.isDirectory) {
compressed = Buffer.alloc(0);
entry.compressedSize = 0;
entry.uncompressedSize = 0;
} else {
compressed = zlib.deflateRawSync(entry.data);
entry.compressedSize = compressed.length;
compressionMethod = 8;
}
const crc = entry.isDirectory ? 0 : crc32(entry.data);
const nameBuf = Buffer.from(entry.name, "utf8");
const localHeaderSize = 30 + nameBuf.length;
const localHeader = Buffer.alloc(localHeaderSize);
writeUint32LE(localHeader, 0, 0x04034b50);
writeUint16LE(localHeader, 4, 20);
writeUint16LE(localHeader, 6, 0);
writeUint16LE(localHeader, 8, compressionMethod);
writeUint16LE(localHeader, 10, 0);
writeUint16LE(localHeader, 12, 0);
writeUint32LE(localHeader, 14, crc);
writeUint32LE(localHeader, 18, entry.compressedSize);
writeUint32LE(localHeader, 22, entry.uncompressedSize);
writeUint16LE(localHeader, 26, nameBuf.length);
writeUint16LE(localHeader, 28, 0);
localHeader.set(nameBuf, 30);
parts.push(localHeader);
parts.push(compressed);
}
const centralDirParts: Buffer[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const nameBuf = Buffer.from(entry.name, "utf8");
const cdEntrySize = 46 + nameBuf.length;
const cd = Buffer.alloc(cdEntrySize);
writeUint32LE(cd, 0, 0x02014b50);
writeUint16LE(cd, 4, 20);
writeUint16LE(cd, 6, 20);
writeUint16LE(cd, 8, 0);
writeUint16LE(cd, 10, entry.isDirectory ? 0 : 8);
writeUint16LE(cd, 12, 0);
writeUint16LE(cd, 14, 0);
writeUint32LE(cd, 16, entry.isDirectory ? 0 : crc32(entry.data));
writeUint32LE(cd, 20, entry.compressedSize);
writeUint32LE(cd, 24, entry.uncompressedSize);
writeUint16LE(cd, 28, nameBuf.length);
writeUint16LE(cd, 30, 0);
writeUint16LE(cd, 32, 0);
writeUint16LE(cd, 34, 0);
writeUint16LE(cd, 36, 0);
writeUint32LE(cd, 38, 0);
writeUint32LE(cd, 42, localOffsets[i]);
cd.set(nameBuf, 46);
centralDirParts.push(cd);
}
const totalCentralDirEntries = entries.length;
const localSize = parts.reduce((s, b) => s + b.length, 0);
const centralSize = centralDirParts.reduce((s, b) => s + b.length, 0);
const eocdSize = 22;
const eocd = Buffer.alloc(eocdSize);
writeUint32LE(eocd, 0, 0x06054b50);
writeUint16LE(eocd, 4, 0);
writeUint16LE(eocd, 6, 0);
writeUint16LE(eocd, 8, totalCentralDirEntries);
writeUint16LE(eocd, 10, totalCentralDirEntries);
writeUint32LE(eocd, 12, centralSize);
writeUint32LE(eocd, 16, localSize);
writeUint16LE(eocd, 20, 0);
const totalSize = localSize + centralSize + eocdSize;
const zip = Buffer.alloc(totalSize);
let offset = 0;
for (const b of parts) {
b.copy(zip, offset);
offset += b.length;
}
for (const b of centralDirParts) {
b.copy(zip, offset);
offset += b.length;
}
eocd.copy(zip, offset);
return zip;
}
export const ZIP_NAME_MAX = 65535;
/** Validate that a zip entry name is a safe relative path (zip-slip guard). */
export function sanitizeZipEntryName(name: string): { valid: boolean; sanitized?: string; error?: string } {
if (!name || typeof name !== "string") return { valid: false, error: "Entry name must be a non-empty string" };
const trimmed = name.trim();
if (trimmed.length === 0) return { valid: false, error: "Entry name cannot be empty" };
if (trimmed.length > ZIP_NAME_MAX) return { valid: false, error: `Entry name too long (max ${ZIP_NAME_MAX} chars)` };
if (trimmed.includes("\\")) return { valid: false, error: "Entry name must use forward slashes" };
if (trimmed.includes("..")) return { valid: false, error: "Entry name cannot contain '..'" };
if (trimmed.startsWith("/")) return { valid: false, error: "Entry name cannot be absolute" };
if (/^[a-zA-Z]:/.test(trimmed)) return { valid: false, error: "Entry name cannot contain a drive letter" };
return { valid: true, sanitized: trimmed };
}
export function zipCreate(entries: Array<{ source: string; name: string }>, outputPath: string): ZipCreateResult {
try {
const validated: Array<{ name: string; data: Buffer; uncompressedSize: number; compressedSize: number; isDirectory: boolean }> = [];
for (const entry of entries) {
const nameValidation = sanitizeZipEntryName(entry.name);
if (!nameValidation.valid) {
return { success: false, outputPath: "", filesCount: 0, totalUncompressedSize: 0, compressedSize: 0, error: nameValidation.error };
}
const buf = fs.readFileSync(expandPath(entry.source));
validated.push({ name: nameValidation.sanitized!, data: buf, uncompressedSize: buf.length, compressedSize: 0, isDirectory: false });
}
const zip = buildZip(validated);
const outDir = path.dirname(expandPath(outputPath));
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(expandPath(outputPath), zip);
const totalUncompressed = validated.reduce((s, e) => s + e.uncompressedSize, 0);
return {
success: true,
outputPath: expandPath(outputPath),
filesCount: validated.length,
totalUncompressedSize: totalUncompressed,
compressedSize: zip.length,
};
} catch (err) {
return {
success: false,
outputPath: "",
filesCount: 0,
totalUncompressedSize: 0,
compressedSize: 0,
error: (err as Error).message,
};
}
}
export function zipExtract(zipPath: string, destDir: string): ZipExtractResult {
try {
const zip = fs.readFileSync(expandPath(zipPath));
const dest = expandPath(destDir);
// Find EOCD: search from end for 0x06054b50
let eocdOffset = -1;
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65557); i--) {
if (zip.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}
if (eocdOffset === -1) return { success: false, extractedFiles: [], error: "Not a valid zip" };
const cdOffset = zip.readUInt32LE(eocdOffset + 16);
const cdEntries = zip.readUInt16LE(eocdOffset + 8);
const extractedFiles: string[] = [];
let offset = cdOffset;
for (let i = 0; i < cdEntries; i++) {
const entrySig = zip.readUInt32LE(offset);
if (entrySig !== 0x02014b50) break;
if (offset + 46 > zip.length) break;
const compSize = zip.readUInt32LE(offset + 20);
const nameLen = zip.readUInt16LE(offset + 28);
const extraLen = zip.readUInt16LE(offset + 30);
const commentLen = zip.readUInt16LE(offset + 32);
const compression = zip.readUInt16LE(offset + 10);
const localHeaderOffset = zip.readUInt32LE(offset + 42);
const name = zip.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
const localSig = zip.readUInt32LE(localHeaderOffset);
if (localSig !== 0x04034b50) { offset += 46 + nameLen + extraLen + commentLen; continue; }
const lNameLen = zip.readUInt16LE(localHeaderOffset + 26);
const lExtraLen = zip.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + lNameLen + lExtraLen;
const raw = zip.subarray(dataOffset, dataOffset + compSize);
const fullPath = path.resolve(dest, name);
const destRoot = path.resolve(dest);
if (!fullPath.startsWith(destRoot + path.sep) && fullPath !== destRoot) {
return { success: false, extractedFiles, error: `Blocked path traversal entry: ${name}` };
}
const parentDir = path.dirname(fullPath);
if (!fs.existsSync(parentDir)) fs.mkdirSync(parentDir, { recursive: true });
let content: Buffer;
if (name.endsWith("/")) {
if (!fs.existsSync(fullPath)) fs.mkdirSync(fullPath, { recursive: true });
content = Buffer.alloc(0);
} else if (compSize === 0) {
content = Buffer.alloc(0);
} else if (compression === 0) {
content = raw;
} else {
content = zlib.inflateRawSync(raw);
}
if (!name.endsWith("/")) fs.writeFileSync(fullPath, content);
extractedFiles.push(name);
offset += 46 + nameLen + extraLen + commentLen;
}
return { success: true, extractedFiles };
} catch (err) {
return { success: false, extractedFiles: [], error: (err as Error).message };
}
}
export function zipList(zipPath: string): ZipListResult {
try {
const zip = fs.readFileSync(expandPath(zipPath));
// Find EOCD
let eocdOffset = -1;
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65557); i--) {
if (zip.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}
if (eocdOffset === -1) return { success: false, entries: [], error: "Not a valid zip" };
const cdOffset = zip.readUInt32LE(eocdOffset + 16);
const cdEntries = zip.readUInt16LE(eocdOffset + 8);
const entries: Array<{ name: string; compressedSize: number; uncompressedSize: number; isDirectory: boolean }> = [];
let offset = cdOffset;
for (let i = 0; i < cdEntries; i++) {
const entrySig = zip.readUInt32LE(offset);
if (entrySig !== 0x02014b50) break;
const compSize = zip.readUInt32LE(offset + 20);
const uncompSize = zip.readUInt32LE(offset + 24);
const nameLen = zip.readUInt16LE(offset + 28);
const name = zip.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
entries.push({
name,
compressedSize: compSize,
uncompressedSize: uncompSize,
isDirectory: name.endsWith("/") || (compSize === 0 && uncompSize === 0),
});
offset += 46 + nameLen + zip.readUInt16LE(offset + 30) + zip.readUInt16LE(offset + 32);
}
return { success: true, entries };
} catch (err) {
return { success: false, entries: [], error: (err as Error).message };
}
}
import * as fs from "fs";
import * as path from "path";
import * as zlib from "zlib";
import { ZipCreateResult, ZipExtractResult, ZipListResult } from "./types";
import { expandPath } from "./securityEnhanced";
// ── Zip Operations (pure JS, no external deps) ──
interface ZipEntry {
name: string;
data: Buffer;
uncompressedSize: number;
compressedSize: number;
isDirectory: boolean;
}
export function crc32(buf: Buffer): number {
let crc = 0xFFFFFFFF;
const table: number[] = [];
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
for (let i = 0; i < buf.length; i++) {
crc = table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
export function writeUint32LE(buf: Buffer, offset: number, value: number): void {
if (value < 0 || value > 0xFFFFFFFF) throw new RangeError(`writeUint32LE: value ${value} out of range`);
buf.writeUInt32LE(value, offset);
}
export function writeUint16LE(buf: Buffer, offset: number, value: number): void {
if (value < 0 || value > 0xFFFF) throw new RangeError(`writeUint16LE: value ${value} out of range`);
buf.writeUInt16LE(value, offset);
}
/**
* Build a valid PKZIP archive.
*/
export function buildZip(entries: ZipEntry[]): Buffer {
const parts: Buffer[] = [];
const localOffsets: number[] = [];
for (const entry of entries) {
const localOffset = parts.reduce((sum, b) => sum + b.length, 0);
localOffsets.push(localOffset);
let compressed: Buffer;
let compressionMethod = 0;
if (entry.isDirectory) {
compressed = Buffer.alloc(0);
entry.compressedSize = 0;
entry.uncompressedSize = 0;
} else {
compressed = zlib.deflateRawSync(entry.data);
entry.compressedSize = compressed.length;
compressionMethod = 8;
}
const crc = entry.isDirectory ? 0 : crc32(entry.data);
const nameBuf = Buffer.from(entry.name, "utf8");
const localHeaderSize = 30 + nameBuf.length;
const localHeader = Buffer.alloc(localHeaderSize);
writeUint32LE(localHeader, 0, 0x04034b50);
writeUint16LE(localHeader, 4, 20);
writeUint16LE(localHeader, 6, 0);
writeUint16LE(localHeader, 8, compressionMethod);
writeUint16LE(localHeader, 10, 0);
writeUint16LE(localHeader, 12, 0);
writeUint32LE(localHeader, 14, crc);
writeUint32LE(localHeader, 18, entry.compressedSize);
writeUint32LE(localHeader, 22, entry.uncompressedSize);
writeUint16LE(localHeader, 26, nameBuf.length);
writeUint16LE(localHeader, 28, 0);
localHeader.set(nameBuf, 30);
parts.push(localHeader);
parts.push(compressed);
}
const centralDirParts: Buffer[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const nameBuf = Buffer.from(entry.name, "utf8");
const cdEntrySize = 46 + nameBuf.length;
const cd = Buffer.alloc(cdEntrySize);
writeUint32LE(cd, 0, 0x02014b50);
writeUint16LE(cd, 4, 20);
writeUint16LE(cd, 6, 20);
writeUint16LE(cd, 8, 0);
writeUint16LE(cd, 10, entry.isDirectory ? 0 : 8);
writeUint16LE(cd, 12, 0);
writeUint16LE(cd, 14, 0);
writeUint32LE(cd, 16, entry.isDirectory ? 0 : crc32(entry.data));
writeUint32LE(cd, 20, entry.compressedSize);
writeUint32LE(cd, 24, entry.uncompressedSize);
writeUint16LE(cd, 28, nameBuf.length);
writeUint16LE(cd, 30, 0);
writeUint16LE(cd, 32, 0);
writeUint16LE(cd, 34, 0);
writeUint16LE(cd, 36, 0);
writeUint32LE(cd, 38, 0);
writeUint32LE(cd, 42, localOffsets[i]);
cd.set(nameBuf, 46);
centralDirParts.push(cd);
}
const totalCentralDirEntries = entries.length;
const localSize = parts.reduce((s, b) => s + b.length, 0);
const centralSize = centralDirParts.reduce((s, b) => s + b.length, 0);
const eocdSize = 22;
const eocd = Buffer.alloc(eocdSize);
writeUint32LE(eocd, 0, 0x06054b50);
writeUint16LE(eocd, 4, 0);
writeUint16LE(eocd, 6, 0);
writeUint16LE(eocd, 8, totalCentralDirEntries);
writeUint16LE(eocd, 10, totalCentralDirEntries);
writeUint32LE(eocd, 12, centralSize);
writeUint32LE(eocd, 16, localSize);
writeUint16LE(eocd, 20, 0);
const totalSize = localSize + centralSize + eocdSize;
const zip = Buffer.alloc(totalSize);
let offset = 0;
for (const b of parts) {
b.copy(zip, offset);
offset += b.length;
}
for (const b of centralDirParts) {
b.copy(zip, offset);
offset += b.length;
}
eocd.copy(zip, offset);
return zip;
}
export const ZIP_NAME_MAX = 65535;
/** Validate that a zip entry name is a safe relative path (zip-slip guard). */
export function sanitizeZipEntryName(name: string): { valid: boolean; sanitized?: string; error?: string } {
if (!name || typeof name !== "string") return { valid: false, error: "Entry name must be a non-empty string" };
const trimmed = name.trim();
if (trimmed.length === 0) return { valid: false, error: "Entry name cannot be empty" };
if (trimmed.length > ZIP_NAME_MAX) return { valid: false, error: `Entry name too long (max ${ZIP_NAME_MAX} chars)` };
if (trimmed.includes("\\")) return { valid: false, error: "Entry name must use forward slashes" };
if (trimmed.includes("..")) return { valid: false, error: "Entry name cannot contain '..'" };
if (trimmed.startsWith("/")) return { valid: false, error: "Entry name cannot be absolute" };
if (/^[a-zA-Z]:/.test(trimmed)) return { valid: false, error: "Entry name cannot contain a drive letter" };
return { valid: true, sanitized: trimmed };
}
export function zipCreate(entries: Array<{ source: string; name: string }>, outputPath: string): ZipCreateResult {
try {
const validated: Array<{ name: string; data: Buffer; uncompressedSize: number; compressedSize: number; isDirectory: boolean }> = [];
for (const entry of entries) {
const nameValidation = sanitizeZipEntryName(entry.name);
if (!nameValidation.valid) {
return { success: false, outputPath: "", filesCount: 0, totalUncompressedSize: 0, compressedSize: 0, error: nameValidation.error };
}
const buf = fs.readFileSync(expandPath(entry.source));
validated.push({ name: nameValidation.sanitized!, data: buf, uncompressedSize: buf.length, compressedSize: 0, isDirectory: false });
}
const zip = buildZip(validated);
const outDir = path.dirname(expandPath(outputPath));
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(expandPath(outputPath), zip);
const totalUncompressed = validated.reduce((s, e) => s + e.uncompressedSize, 0);
return {
success: true,
outputPath: expandPath(outputPath),
filesCount: validated.length,
totalUncompressedSize: totalUncompressed,
compressedSize: zip.length,
};
} catch (err) {
return {
success: false,
outputPath: "",
filesCount: 0,
totalUncompressedSize: 0,
compressedSize: 0,
error: (err as Error).message,
};
}
}
export function zipExtract(zipPath: string, destDir: string): ZipExtractResult {
try {
const zip = fs.readFileSync(expandPath(zipPath));
const dest = expandPath(destDir);
// Find EOCD: search from end for 0x06054b50
let eocdOffset = -1;
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65557); i--) {
if (zip.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}
if (eocdOffset === -1) return { success: false, extractedFiles: [], error: "Not a valid zip" };
const cdOffset = zip.readUInt32LE(eocdOffset + 16);
const cdEntries = zip.readUInt16LE(eocdOffset + 8);
const extractedFiles: string[] = [];
let offset = cdOffset;
for (let i = 0; i < cdEntries; i++) {
const entrySig = zip.readUInt32LE(offset);
if (entrySig !== 0x02014b50) break;
if (offset + 46 > zip.length) break;
const compSize = zip.readUInt32LE(offset + 20);
const nameLen = zip.readUInt16LE(offset + 28);
const extraLen = zip.readUInt16LE(offset + 30);
const commentLen = zip.readUInt16LE(offset + 32);
const compression = zip.readUInt16LE(offset + 10);
const localHeaderOffset = zip.readUInt32LE(offset + 42);
const name = zip.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
const localSig = zip.readUInt32LE(localHeaderOffset);
if (localSig !== 0x04034b50) { offset += 46 + nameLen + extraLen + commentLen; continue; }
const lNameLen = zip.readUInt16LE(localHeaderOffset + 26);
const lExtraLen = zip.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + lNameLen + lExtraLen;
const raw = zip.subarray(dataOffset, dataOffset + compSize);
const fullPath = path.resolve(dest, name);
const destRoot = path.resolve(dest);
if (!fullPath.startsWith(destRoot + path.sep) && fullPath !== destRoot) {
return { success: false, extractedFiles, error: `Blocked path traversal entry: ${name}` };
}
const parentDir = path.dirname(fullPath);
if (!fs.existsSync(parentDir)) fs.mkdirSync(parentDir, { recursive: true });
let content: Buffer;
if (name.endsWith("/")) {
if (!fs.existsSync(fullPath)) fs.mkdirSync(fullPath, { recursive: true });
content = Buffer.alloc(0);
} else if (compSize === 0) {
content = Buffer.alloc(0);
} else if (compression === 0) {
content = raw;
} else {
content = zlib.inflateRawSync(raw);
}
if (!name.endsWith("/")) fs.writeFileSync(fullPath, content);
extractedFiles.push(name);
offset += 46 + nameLen + extraLen + commentLen;
}
return { success: true, extractedFiles };
} catch (err) {
return { success: false, extractedFiles: [], error: (err as Error).message };
}
}
export function zipList(zipPath: string): ZipListResult {
try {
const zip = fs.readFileSync(expandPath(zipPath));
// Find EOCD
let eocdOffset = -1;
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65557); i--) {
if (zip.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}
if (eocdOffset === -1) return { success: false, entries: [], error: "Not a valid zip" };
const cdOffset = zip.readUInt32LE(eocdOffset + 16);
const cdEntries = zip.readUInt16LE(eocdOffset + 8);
const entries: Array<{ name: string; compressedSize: number; uncompressedSize: number; isDirectory: boolean }> = [];
let offset = cdOffset;
for (let i = 0; i < cdEntries; i++) {
const entrySig = zip.readUInt32LE(offset);
if (entrySig !== 0x02014b50) break;
const compSize = zip.readUInt32LE(offset + 20);
const uncompSize = zip.readUInt32LE(offset + 24);
const nameLen = zip.readUInt16LE(offset + 28);
const name = zip.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
entries.push({
name,
compressedSize: compSize,
uncompressedSize: uncompSize,
isDirectory: name.endsWith("/") || (compSize === 0 && uncompSize === 0),
});
offset += 46 + nameLen + zip.readUInt16LE(offset + 30) + zip.readUInt16LE(offset + 32);
}
return { success: true, entries };
} catch (err) {
return { success: false, entries: [], error: (err as Error).message };
}
}