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.

273 lines · 11.4 KBRaw History
1/**
2 * Minimal tar and zip writers for source archives (zipball/tarball) and run
3 * log bundles, and a zip reader for the small zips the server ships itself.
4 * Entries are buffered, which is fine for the size limits used.
5 */
6
7export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true };
8
9const enc = new TextEncoder();
10
11// ---------------------------------------------------------------- tar (ustar + pax for long names)
12
13function octal(n: number, width: number): string {
14 return `${n.toString(8).padStart(width - 1, "0")}\0`;
15}
16
17function tarHeader(name: string, size: number, mode: number, type: string, mtime: number, linkname = ""): Uint8Array {
18 const h = new Uint8Array(512);
19 const put = (s: string, off: number, len: number) => h.set(enc.encode(s).subarray(0, len), off);
20 put(name, 0, 100);
21 put(octal(mode, 8), 100, 8);
22 put(octal(0, 8), 108, 8);
23 put(octal(0, 8), 116, 8);
24 put(octal(size, 12), 124, 12);
25 put(octal(mtime, 12), 136, 12);
26 put(" ", 148, 8);
27 put(type, 156, 1);
28 put(linkname, 157, 100);
29 put("ustar\0", 257, 6);
30 put("00", 263, 2);
31 let sum = 0;
32 for (const b of h) sum += b;
33 put(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8);
34 return h;
35}
36
37function pad512(n: number): Uint8Array {
38 return new Uint8Array((512 - (n % 512)) % 512);
39}
40
41function paxRecord(key: string, value: string): string {
42 const body = ` ${key}=${value}\n`;
43 const bytes = enc.encode(body).length;
44 let len = bytes + 1;
45 while (len !== bytes + String(len).length) len = bytes + String(len).length;
46 return `${len}${body}`;
47}
48
49/** A pax global header carrying `comment` (git archive puts the commit id there). */
50export function tarStart(mtime: number, comment?: string): Uint8Array[] {
51 if (!comment) return [];
52 const pax = enc.encode(paxRecord("comment", comment));
53 return [tarHeader("pax_global_header", pax.length, 0o666, "g", mtime), pax, pad512(pax.length)];
54}
55
56/** The records for one entry: an optional pax header for long names, the header, the content and padding. */
57export function tarEntry(e: ArchiveEntry, mtime: number): Uint8Array[] {
58 const parts: Uint8Array[] = [];
59 const isDir = "dir" in e;
60 const isLink = !isDir && e.mode === "120000";
61 const name = isDir && !e.path.endsWith("/") ? `${e.path}/` : e.path;
62 const link = isLink ? new TextDecoder().decode(e.content) : "";
63 if (enc.encode(name).length > 100 || enc.encode(link).length > 100) {
64 const pax = enc.encode(paxRecord("path", name) + (link ? paxRecord("linkpath", link) : ""));
65 parts.push(tarHeader("PaxHeader", pax.length, 0o644, "x", mtime), pax, pad512(pax.length));
66 }
67 if (isDir) parts.push(tarHeader(name, 0, 0o775, "5", mtime));
68 else if (isLink) parts.push(tarHeader(name, 0, 0o777, "2", mtime, link));
69 else parts.push(tarHeader(name, e.content.length, e.mode === "100755" ? 0o775 : 0o664, "0", mtime), e.content, pad512(e.content.length));
70 return parts;
71}
72
73/**
74 * The records before a regular file's `size` bytes, for writing its content as a stream (account exports):
75 * the content follows, then {@link tarPadding}.
76 */
77export function tarFileHeader(path: string, size: number, mtime: number): Uint8Array[] {
78 const parts: Uint8Array[] = [];
79 if (enc.encode(path).length > 100) {
80 const pax = enc.encode(paxRecord("path", path));
81 parts.push(tarHeader("PaxHeader", pax.length, 0o644, "x", mtime), pax, pad512(pax.length));
82 }
83 parts.push(tarHeader(path, size, 0o644, "0", mtime));
84 return parts;
85}
86
87/** The zeros that fill a file's last 512-byte block. */
88export const tarPadding = (size: number): Uint8Array => pad512(size);
89
90/** The two zero blocks that end a tar archive. */
91export const tarEnd = (): Uint8Array => new Uint8Array(1024);
92
93/** An uncompressed tar archive; gzip it with CompressionStream("gzip"). */
94export function tar(entries: ArchiveEntry[], mtime: number, comment?: string): Uint8Array {
95 return concat([...tarStart(mtime, comment), ...entries.flatMap((e) => tarEntry(e, mtime)), tarEnd()]);
96}
97
98// ---------------------------------------------------------------- zip
99
100let CRC_TABLE: Uint32Array | null = null;
101
102export function crc32(data: Uint8Array): number {
103 if (!CRC_TABLE) {
104 CRC_TABLE = new Uint32Array(256);
105 for (let n = 0; n < 256; n++) {
106 let c = n;
107 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
108 CRC_TABLE[n] = c >>> 0;
109 }
110 }
111 let crc = 0xffffffff;
112 for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
113 return (crc ^ 0xffffffff) >>> 0;
114}
115
116async function deflateRaw(data: Uint8Array): Promise<Uint8Array> {
117 const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream("deflate-raw"));
118 return new Uint8Array(await new Response(stream).arrayBuffer());
119}
120
121function dosTime(seconds: number): { time: number; date: number } {
122 const d = new Date(seconds * 1000);
123 const year = Math.max(1980, d.getUTCFullYear());
124 return { time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2), date: ((year - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate() };
125}
126
127/** Classic zip (no ZIP64) holds at most this many entries and 4 GiB. */
128export const ZIP_MAX_ENTRIES = 0xffff;
129
130/**
131 * Writes a zip one entry at a time (deflate, UTF-8 names, unix modes), keeping
132 * only the central directory in memory. Refuses what classic zip can't hold
133 * rather than writing a corrupt archive.
134 */
135export class ZipWriter {
136 private central: Uint8Array[] = [];
137 private offset = 0;
138 private count = 0;
139 private readonly time: number;
140 private readonly date: number;
141
142 constructor(mtime: number) {
143 ({ time: this.time, date: this.date } = dosTime(mtime));
144 }
145
146 async entry(e: ArchiveEntry): Promise<Uint8Array[]> {
147 if (this.count >= ZIP_MAX_ENTRIES) throw new RangeError(`zip archives hold at most ${ZIP_MAX_ENTRIES} entries`);
148 const isDir = "dir" in e;
149 const name = enc.encode(isDir && !e.path.endsWith("/") ? `${e.path}/` : e.path);
150 const raw = isDir ? new Uint8Array(0) : e.content;
151 const deflated = raw.length ? await deflateRaw(raw) : raw;
152 const useDeflate = deflated.length < raw.length;
153 const data = useDeflate ? deflated : raw;
154 if (this.offset + 30 + name.length + data.length > 0xffffffff) throw new RangeError("zip archives are limited to 4 GiB");
155 const crc = crc32(raw);
156 const unixMode = isDir ? 0o40775 : e.mode === "120000" ? 0o120777 : e.mode === "100755" ? 0o100775 : 0o100664;
157 const lh = new DataView(new ArrayBuffer(30));
158 lh.setUint32(0, 0x04034b50, true);
159 lh.setUint16(4, 20, true);
160 lh.setUint16(6, 0x0800, true);
161 lh.setUint16(8, useDeflate ? 8 : 0, true);
162 lh.setUint16(10, this.time, true);
163 lh.setUint16(12, this.date, true);
164 lh.setUint32(14, crc, true);
165 lh.setUint32(18, data.length, true);
166 lh.setUint32(22, raw.length, true);
167 lh.setUint16(26, name.length, true);
168 const ch = new DataView(new ArrayBuffer(46));
169 ch.setUint32(0, 0x02014b50, true);
170 ch.setUint16(4, (3 << 8) | 20, true);
171 ch.setUint16(6, 20, true);
172 ch.setUint16(8, 0x0800, true);
173 ch.setUint16(10, useDeflate ? 8 : 0, true);
174 ch.setUint16(12, this.time, true);
175 ch.setUint16(14, this.date, true);
176 ch.setUint32(16, crc, true);
177 ch.setUint32(20, data.length, true);
178 ch.setUint32(24, raw.length, true);
179 ch.setUint16(28, name.length, true);
180 ch.setUint32(38, ((unixMode << 16) | (isDir ? 0x10 : 0)) >>> 0, true);
181 ch.setUint32(42, this.offset, true);
182 this.central.push(new Uint8Array(ch.buffer), name);
183 this.offset += 30 + name.length + data.length;
184 this.count++;
185 return [new Uint8Array(lh.buffer), name, data];
186 }
187
188 /** The central directory and end record; `comment` becomes the archive comment. */
189 end(comment = ""): Uint8Array[] {
190 const cd = concat(this.central);
191 const note = enc.encode(comment).subarray(0, 0xffff);
192 const end = new DataView(new ArrayBuffer(22));
193 end.setUint32(0, 0x06054b50, true);
194 end.setUint16(8, this.count, true);
195 end.setUint16(10, this.count, true);
196 end.setUint32(12, cd.length, true);
197 end.setUint32(16, this.offset, true);
198 end.setUint16(20, note.length, true);
199 return [cd, new Uint8Array(end.buffer), note];
200 }
201}
202
203/** A zip archive (deflate, UTF-8 names, unix modes); `comment` is stored as the archive comment. */
204export async function zip(entries: ArchiveEntry[], mtime: number, comment = ""): Promise<Uint8Array> {
205 const w = new ZipWriter(mtime);
206 const parts: Uint8Array[] = [];
207 for (const e of entries) parts.push(...(await w.entry(e)));
208 parts.push(...w.end(comment));
209 return concat(parts);
210}
211
212async function inflateRaw(data: Uint8Array): Promise<Uint8Array> {
213 const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
214 return new Uint8Array(await new Response(stream).arrayBuffer());
215}
216
217/**
218 * The files of a classic zip (stored or deflated entries, read through the central directory), with
219 * their unix modes as git modes. For zips the server ships itself, such as the Claude Code plugins:
220 * it checks each entry's CRC and refuses anything else rather than guess.
221 */
222export async function unzip(data: Uint8Array): Promise<ArchiveEntry[]> {
223 const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
224 let end = data.length - 22;
225 while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end--;
226 if (end < 0) throw new Error("not a zip archive");
227 const count = view.getUint16(end + 10, true);
228 let at = view.getUint32(end + 16, true);
229 const out: ArchiveEntry[] = [];
230 for (let i = 0; i < count; i++) {
231 if (view.getUint32(at, true) !== 0x02014b50) throw new Error("corrupt zip central directory");
232 const method = view.getUint16(at + 10, true);
233 const crc = view.getUint32(at + 16, true);
234 const size = view.getUint32(at + 20, true);
235 const nameLen = view.getUint16(at + 28, true);
236 const extraLen = view.getUint16(at + 30, true);
237 const commentLen = view.getUint16(at + 32, true);
238 const unixMode = view.getUint32(at + 38, true) >>> 16;
239 const local = view.getUint32(at + 42, true);
240 const path = new TextDecoder().decode(data.subarray(at + 46, at + 46 + nameLen));
241 at += 46 + nameLen + extraLen + commentLen;
242 if (path.endsWith("/")) {
243 out.push({ path, dir: true });
244 continue;
245 }
246 if (view.getUint32(local, true) !== 0x04034b50) throw new Error(`corrupt zip entry ${path}`);
247 const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
248 const raw = data.subarray(start, start + size);
249 if (method !== 0 && method !== 8) throw new Error(`zip entry ${path} uses compression method ${method}`);
250 const content = method === 8 ? await inflateRaw(raw) : raw.slice();
251 if (crc32(content) !== crc) throw new Error(`zip entry ${path} fails its CRC check`);
252 const kind = unixMode >> 12;
253 out.push({ path, mode: kind === 0o12 ? "120000" : unixMode & 0o111 ? "100755" : "100644", content });
254 }
255 return out;
256}
257
258export function concat(parts: Uint8Array[]): Uint8Array {
259 let n = 0;
260 for (const p of parts) n += p.length;
261 const out = new Uint8Array(n);
262 let o = 0;
263 for (const p of parts) {
264 out.set(p, o);
265 o += p.length;
266 }
267 return out;
268}
269
270export async function gzip(data: Uint8Array): Promise<Uint8Array> {
271 const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream("gzip"));
272 return new Uint8Array(await new Response(stream).arrayBuffer());
273}