| 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 | |
| 7 | export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true }; |
| 8 | |
| 9 | const enc = new TextEncoder(); |
| 10 | |
| 11 | // ---------------------------------------------------------------- tar (ustar + pax for long names) |
| 12 | |
| 13 | function octal(n: number, width: number): string { |
| 14 | return `${n.toString(8).padStart(width - 1, "0")}\0`; |
| 15 | } |
| 16 | |
| 17 | function 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 | |
| 37 | function pad512(n: number): Uint8Array { |
| 38 | return new Uint8Array((512 - (n % 512)) % 512); |
| 39 | } |
| 40 | |
| 41 | function 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). */ |
| 50 | export 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. */ |
| 57 | export 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 | */ |
| 77 | export 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. */ |
| 88 | export const tarPadding = (size: number): Uint8Array => pad512(size); |
| 89 | |
| 90 | /** The two zero blocks that end a tar archive. */ |
| 91 | export const tarEnd = (): Uint8Array => new Uint8Array(1024); |
| 92 | |
| 93 | /** An uncompressed tar archive; gzip it with CompressionStream("gzip"). */ |
| 94 | export 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 | |
| 100 | let CRC_TABLE: Uint32Array | null = null; |
| 101 | |
| 102 | export 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 | |
| 116 | async 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 | |
| 121 | function 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. */ |
| 128 | export 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 | */ |
| 135 | export 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. */ |
| 204 | export 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 | |
| 212 | async 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 | */ |
| 222 | export 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 | |
| 258 | export 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 | |
| 270 | export 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 | } |