| 1 | import { createHash } from "node:crypto"; |
| 2 | import { createReadStream, createWriteStream } from "node:fs"; |
| 3 | import { chmod, mkdir, open, stat, type FileHandle } from "node:fs/promises"; |
| 4 | import path from "node:path"; |
| 5 | import { Transform } from "node:stream"; |
| 6 | import { pipeline } from "node:stream/promises"; |
| 7 | import zlib from "node:zlib"; |
| 8 | |
| 9 | /** |
| 10 | * Minimal ZIP writer and reader (deflate or store, ZIP64 when needed), for |
| 11 | * artifacts in the format upload-artifact and download-artifact use. |
| 12 | */ |
| 13 | |
| 14 | const CRC_TABLE = (() => { |
| 15 | const t = new Uint32Array(256); |
| 16 | for (let n = 0; n < 256; n++) { |
| 17 | let c = n; |
| 18 | for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; |
| 19 | t[n] = c >>> 0; |
| 20 | } |
| 21 | return t; |
| 22 | })(); |
| 23 | |
| 24 | export function crc32(buf: Uint8Array, prev = 0): number { |
| 25 | const native = (zlib as unknown as { crc32?: (d: Uint8Array, v?: number) => number }).crc32; |
| 26 | if (native) return native(buf, prev); |
| 27 | let c = (prev ^ 0xffffffff) >>> 0; |
| 28 | for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]!) & 0xff]! ^ (c >>> 8); |
| 29 | return (c ^ 0xffffffff) >>> 0; |
| 30 | } |
| 31 | |
| 32 | const MAX32 = 0xffffffff; |
| 33 | /** Files at least this large get ZIP64 sizes up front (deflate can grow incompressible data a little). */ |
| 34 | const ZIP64_THRESHOLD = 0xf0000000; |
| 35 | |
| 36 | function dosDateTime(d: Date): { date: number; time: number } { |
| 37 | const year = Math.max(1980, d.getFullYear()); |
| 38 | return { |
| 39 | date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), |
| 40 | time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2), |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | export interface ZipEntry { |
| 45 | /** File to read. */ |
| 46 | source: string; |
| 47 | /** Path inside the archive, with `/` separators and no leading slash. */ |
| 48 | name: string; |
| 49 | } |
| 50 | |
| 51 | /** Writes `entries` to `out`. Returns the archive's size and SHA-256 (hex). */ |
| 52 | export async function writeZip(out: string, entries: ZipEntry[], level = 6): Promise<{ size: number; sha256: string }> { |
| 53 | const fh = await open(out, "w"); |
| 54 | let pos = 0; |
| 55 | const write = async (buf: Buffer) => { |
| 56 | await fh.write(buf, 0, buf.length, pos); |
| 57 | pos += buf.length; |
| 58 | }; |
| 59 | const central: Buffer[] = []; |
| 60 | try { |
| 61 | for (const e of entries) { |
| 62 | const st = await stat(e.source); |
| 63 | const name = Buffer.from(e.name.replace(/^\/+/, ""), "utf8"); |
| 64 | const { date, time } = dosDateTime(st.mtime); |
| 65 | const method = level === 0 ? 0 : 8; |
| 66 | const big = st.size >= ZIP64_THRESHOLD; |
| 67 | const offset = pos; |
| 68 | const local = Buffer.alloc(30); |
| 69 | local.writeUInt32LE(0x04034b50, 0); |
| 70 | local.writeUInt16LE(big ? 45 : 20, 4); |
| 71 | local.writeUInt16LE(0x0800, 6); // UTF-8 names |
| 72 | local.writeUInt16LE(method, 8); |
| 73 | local.writeUInt16LE(time, 10); |
| 74 | local.writeUInt16LE(date, 12); |
| 75 | local.writeUInt16LE(name.length, 26); |
| 76 | local.writeUInt16LE(big ? 20 : 0, 28); |
| 77 | await write(local); |
| 78 | await write(name); |
| 79 | const extraAt = pos; |
| 80 | if (big) await write(Buffer.alloc(20)); |
| 81 | let crc = 0; |
| 82 | let compressed = 0; |
| 83 | const counter = new Transform({ |
| 84 | transform(chunk: Buffer, _enc, cb) { |
| 85 | crc = crc32(chunk, crc); |
| 86 | cb(null, chunk); |
| 87 | }, |
| 88 | }); |
| 89 | const sink = async (source: AsyncIterable<Buffer>) => { |
| 90 | for await (const chunk of source) { |
| 91 | await write(chunk); |
| 92 | compressed += chunk.length; |
| 93 | } |
| 94 | }; |
| 95 | if (method === 8) await pipeline(createReadStream(e.source), counter, zlib.createDeflateRaw({ level }), sink); |
| 96 | else await pipeline(createReadStream(e.source), counter, sink); |
| 97 | // Patch sizes and CRC into the local header. |
| 98 | const fix = Buffer.alloc(12); |
| 99 | fix.writeUInt32LE(crc >>> 0, 0); |
| 100 | fix.writeUInt32LE(big ? MAX32 : compressed, 4); |
| 101 | fix.writeUInt32LE(big ? MAX32 : st.size, 8); |
| 102 | await fh.write(fix, 0, 12, offset + 14); |
| 103 | if (big) { |
| 104 | const z = Buffer.alloc(20); |
| 105 | z.writeUInt16LE(0x0001, 0); |
| 106 | z.writeUInt16LE(16, 2); |
| 107 | z.writeBigUInt64LE(BigInt(st.size), 4); |
| 108 | z.writeBigUInt64LE(BigInt(compressed), 12); |
| 109 | await fh.write(z, 0, 20, extraAt); |
| 110 | } else if (compressed > MAX32) { |
| 111 | throw new Error(`${e.name} does not fit in a 32-bit zip entry`); |
| 112 | } |
| 113 | // Central directory record, with ZIP64 fields for whatever overflows. |
| 114 | const z64: bigint[] = []; |
| 115 | if (big) z64.push(BigInt(st.size), BigInt(compressed)); |
| 116 | if (offset >= MAX32) z64.push(BigInt(offset)); |
| 117 | const extra = Buffer.alloc(z64.length ? 4 + 8 * z64.length : 0); |
| 118 | if (z64.length) { |
| 119 | extra.writeUInt16LE(0x0001, 0); |
| 120 | extra.writeUInt16LE(8 * z64.length, 2); |
| 121 | z64.forEach((v, i) => extra.writeBigUInt64LE(v, 4 + 8 * i)); |
| 122 | } |
| 123 | const cd = Buffer.alloc(46); |
| 124 | cd.writeUInt32LE(0x02014b50, 0); |
| 125 | cd.writeUInt16LE((3 << 8) | (z64.length ? 45 : 20), 4); // made by Unix |
| 126 | cd.writeUInt16LE(z64.length ? 45 : 20, 6); |
| 127 | cd.writeUInt16LE(0x0800, 8); |
| 128 | cd.writeUInt16LE(method, 10); |
| 129 | cd.writeUInt16LE(time, 12); |
| 130 | cd.writeUInt16LE(date, 14); |
| 131 | cd.writeUInt32LE(crc >>> 0, 16); |
| 132 | cd.writeUInt32LE(big ? MAX32 : compressed, 20); |
| 133 | cd.writeUInt32LE(big ? MAX32 : st.size, 24); |
| 134 | cd.writeUInt16LE(name.length, 28); |
| 135 | cd.writeUInt16LE(extra.length, 30); |
| 136 | cd.writeUInt32LE((((0o100000 | (st.mode & 0o777)) << 16) >>> 0) as number, 38); |
| 137 | cd.writeUInt32LE(offset >= MAX32 ? MAX32 : offset, 42); |
| 138 | central.push(cd, name, extra); |
| 139 | } |
| 140 | const cdStart = pos; |
| 141 | for (const b of central) await write(b); |
| 142 | const cdSize = pos - cdStart; |
| 143 | const zip64 = entries.length >= 0xffff || cdStart >= MAX32 || cdSize >= MAX32; |
| 144 | if (zip64) { |
| 145 | const eocd64At = pos; |
| 146 | const r = Buffer.alloc(56); |
| 147 | r.writeUInt32LE(0x06064b50, 0); |
| 148 | r.writeBigUInt64LE(44n, 4); |
| 149 | r.writeUInt16LE((3 << 8) | 45, 12); |
| 150 | r.writeUInt16LE(45, 14); |
| 151 | r.writeBigUInt64LE(BigInt(entries.length), 24); |
| 152 | r.writeBigUInt64LE(BigInt(entries.length), 32); |
| 153 | r.writeBigUInt64LE(BigInt(cdSize), 40); |
| 154 | r.writeBigUInt64LE(BigInt(cdStart), 48); |
| 155 | await write(r); |
| 156 | const loc = Buffer.alloc(20); |
| 157 | loc.writeUInt32LE(0x07064b50, 0); |
| 158 | loc.writeBigUInt64LE(BigInt(eocd64At), 8); |
| 159 | loc.writeUInt32LE(1, 16); |
| 160 | await write(loc); |
| 161 | } |
| 162 | const end = Buffer.alloc(22); |
| 163 | end.writeUInt32LE(0x06054b50, 0); |
| 164 | end.writeUInt16LE(Math.min(entries.length, 0xffff), 8); |
| 165 | end.writeUInt16LE(Math.min(entries.length, 0xffff), 10); |
| 166 | end.writeUInt32LE(Math.min(cdSize, MAX32), 12); |
| 167 | end.writeUInt32LE(Math.min(cdStart, MAX32), 16); |
| 168 | await write(end); |
| 169 | } finally { |
| 170 | await fh.close(); |
| 171 | } |
| 172 | return { size: pos, sha256: await sha256File(out) }; |
| 173 | } |
| 174 | |
| 175 | export async function sha256File(file: string): Promise<string> { |
| 176 | const h = createHash("sha256"); |
| 177 | await pipeline(createReadStream(file), h); |
| 178 | return h.digest("hex"); |
| 179 | } |
| 180 | |
| 181 | interface CentralEntry { |
| 182 | name: string; |
| 183 | method: number; |
| 184 | crc: number; |
| 185 | compressedSize: number; |
| 186 | size: number; |
| 187 | offset: number; |
| 188 | mode: number; |
| 189 | } |
| 190 | |
| 191 | async function readAt(fh: FileHandle, position: number, length: number): Promise<Buffer> { |
| 192 | const buf = Buffer.alloc(length); |
| 193 | const { bytesRead } = await fh.read(buf, 0, length, position); |
| 194 | return buf.subarray(0, bytesRead); |
| 195 | } |
| 196 | |
| 197 | async function centralDirectory(fh: FileHandle, fileSize: number): Promise<CentralEntry[]> { |
| 198 | const tailLen = Math.min(fileSize, 22 + 0xffff); |
| 199 | const tail = await readAt(fh, fileSize - tailLen, tailLen); |
| 200 | let at = -1; |
| 201 | for (let i = tail.length - 22; i >= 0; i--) { |
| 202 | if (tail.readUInt32LE(i) === 0x06054b50) { |
| 203 | at = i; |
| 204 | break; |
| 205 | } |
| 206 | } |
| 207 | if (at < 0) throw new Error("not a zip file (no end of central directory)"); |
| 208 | let count = tail.readUInt16LE(at + 10); |
| 209 | let cdSize = tail.readUInt32LE(at + 12); |
| 210 | let cdStart = tail.readUInt32LE(at + 16); |
| 211 | const eocdPos = fileSize - tailLen + at; |
| 212 | if (eocdPos >= 20) { |
| 213 | const loc = await readAt(fh, eocdPos - 20, 20); |
| 214 | if (loc.readUInt32LE(0) === 0x07064b50) { |
| 215 | const r = await readAt(fh, Number(loc.readBigUInt64LE(8)), 56); |
| 216 | if (r.readUInt32LE(0) !== 0x06064b50) throw new Error("corrupt zip64 end of central directory"); |
| 217 | count = Number(r.readBigUInt64LE(32)); |
| 218 | cdSize = Number(r.readBigUInt64LE(40)); |
| 219 | cdStart = Number(r.readBigUInt64LE(48)); |
| 220 | } |
| 221 | } |
| 222 | const cd = await readAt(fh, cdStart, cdSize); |
| 223 | const out: CentralEntry[] = []; |
| 224 | let p = 0; |
| 225 | for (let i = 0; i < count; i++) { |
| 226 | if (cd.readUInt32LE(p) !== 0x02014b50) throw new Error("corrupt zip central directory"); |
| 227 | const nameLen = cd.readUInt16LE(p + 28); |
| 228 | const extraLen = cd.readUInt16LE(p + 30); |
| 229 | const commentLen = cd.readUInt16LE(p + 32); |
| 230 | const e: CentralEntry = { |
| 231 | name: cd.subarray(p + 46, p + 46 + nameLen).toString("utf8"), |
| 232 | method: cd.readUInt16LE(p + 10), |
| 233 | crc: cd.readUInt32LE(p + 16), |
| 234 | compressedSize: cd.readUInt32LE(p + 20), |
| 235 | size: cd.readUInt32LE(p + 24), |
| 236 | offset: cd.readUInt32LE(p + 42), |
| 237 | mode: cd.readUInt8(p + 5) === 3 ? (cd.readUInt32LE(p + 38) >>> 16) & 0o777 : 0, |
| 238 | }; |
| 239 | // ZIP64 extra: the fields that are 0xffffffff, in order. |
| 240 | let x = p + 46 + nameLen; |
| 241 | const xEnd = x + extraLen; |
| 242 | while (x + 4 <= xEnd) { |
| 243 | const id = cd.readUInt16LE(x); |
| 244 | const len = cd.readUInt16LE(x + 2); |
| 245 | if (id === 0x0001) { |
| 246 | let q = x + 4; |
| 247 | if (e.size === MAX32) (e.size = Number(cd.readBigUInt64LE(q))), (q += 8); |
| 248 | if (e.compressedSize === MAX32) (e.compressedSize = Number(cd.readBigUInt64LE(q))), (q += 8); |
| 249 | if (e.offset === MAX32) e.offset = Number(cd.readBigUInt64LE(q)); |
| 250 | } |
| 251 | x += 4 + len; |
| 252 | } |
| 253 | out.push(e); |
| 254 | p += 46 + nameLen + extraLen + commentLen; |
| 255 | } |
| 256 | return out; |
| 257 | } |
| 258 | |
| 259 | /** Entry names that would escape the destination are refused. */ |
| 260 | export function safeEntryPath(dest: string, name: string): string { |
| 261 | const clean = name.replace(/\\/g, "/"); |
| 262 | if (clean.startsWith("/") || /^[A-Za-z]:/.test(clean) || clean.split("/").includes("..")) throw new Error(`refusing unsafe path in archive: ${name}`); |
| 263 | const target = path.resolve(dest, clean); |
| 264 | if (target !== path.resolve(dest) && !target.startsWith(path.resolve(dest) + path.sep)) throw new Error(`refusing unsafe path in archive: ${name}`); |
| 265 | return target; |
| 266 | } |
| 267 | |
| 268 | /** Extracts every entry of `zipFile` under `dest` (overwriting). Returns the number of files written. */ |
| 269 | export async function extractZip(zipFile: string, dest: string): Promise<number> { |
| 270 | const fh = await open(zipFile, "r"); |
| 271 | let files = 0; |
| 272 | try { |
| 273 | const entries = await centralDirectory(fh, (await fh.stat()).size); |
| 274 | await mkdir(dest, { recursive: true }); |
| 275 | for (const e of entries) { |
| 276 | const target = safeEntryPath(dest, e.name); |
| 277 | if (e.name.endsWith("/")) { |
| 278 | await mkdir(target, { recursive: true }); |
| 279 | continue; |
| 280 | } |
| 281 | if (e.method !== 0 && e.method !== 8) throw new Error(`unsupported compression method ${e.method} for ${e.name}`); |
| 282 | const local = await readAt(fh, e.offset, 30); |
| 283 | if (local.readUInt32LE(0) !== 0x04034b50) throw new Error(`corrupt local header for ${e.name}`); |
| 284 | const start = e.offset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28); |
| 285 | await mkdir(path.dirname(target), { recursive: true }); |
| 286 | let crc = 0; |
| 287 | const check = new Transform({ |
| 288 | transform(chunk: Buffer, _enc, cb) { |
| 289 | crc = crc32(chunk, crc); |
| 290 | cb(null, chunk); |
| 291 | }, |
| 292 | }); |
| 293 | const raw = e.compressedSize ? createReadStream(zipFile, { start, end: start + e.compressedSize - 1 }) : null; |
| 294 | const out = createWriteStream(target); |
| 295 | if (!raw) out.end(); |
| 296 | else if (e.method === 8) await pipeline(raw, zlib.createInflateRaw(), check, out); |
| 297 | else await pipeline(raw, check, out); |
| 298 | if (raw && crc >>> 0 !== e.crc >>> 0) throw new Error(`CRC mismatch for ${e.name}`); |
| 299 | if (!raw) await new Promise<void>((r) => out.once("close", () => r())); |
| 300 | if (e.mode & 0o111) await chmod(target, e.mode).catch(() => {}); |
| 301 | files++; |
| 302 | } |
| 303 | } finally { |
| 304 | await fh.close(); |
| 305 | } |
| 306 | return files; |
| 307 | } |