Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

307 lines · 11.7 KBRaw History
1import { createHash } from "node:crypto";
2import { createReadStream, createWriteStream } from "node:fs";
3import { chmod, mkdir, open, stat, type FileHandle } from "node:fs/promises";
4import path from "node:path";
5import { Transform } from "node:stream";
6import { pipeline } from "node:stream/promises";
7import 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
14const 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
24export 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
32const MAX32 = 0xffffffff;
33/** Files at least this large get ZIP64 sizes up front (deflate can grow incompressible data a little). */
34const ZIP64_THRESHOLD = 0xf0000000;
35
36function 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
44export 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). */
52export 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
175export 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
181interface CentralEntry {
182 name: string;
183 method: number;
184 crc: number;
185 compressedSize: number;
186 size: number;
187 offset: number;
188 mode: number;
189}
190
191async 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
197async 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. */
260export 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. */
269export 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}