/** * Extract a gzipped tarball (e.g. a GitHub repository archive) fully in * memory. Bounded so a hostile or oversized archive can't exhaust the Worker. */ import { createGzipDecoder, unpackTar } from "modern-tar"; const MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024; const MAX_TAR_FILES = 5000; const MAX_FILE_BYTES = 5 * 1024 * 1024; const LEADING_DOT_SLASH = /^\.\//; async function collectStream( stream: ReadableStream, limit: number, ): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.length; if (total > limit) throw new Error(`Archive exceeds ${limit} bytes decompressed`); chunks.push(value); } } finally { reader.releaseLock(); } const out = new Uint8Array(total); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.length; } return out; } /** * Files in the archive, keyed by path with the archive's single top-level * directory removed (GitHub archives are `owner-repo-sha/…`). Only regular * files whose path matches `keep` (when given) are returned. */ export async function extractTarball( data: ArrayBuffer, keep?: (path: string) => boolean, ): Promise> { // Decompress fully first: piping straight into the tar decoder deadlocks in // workerd (the decoder's pull waits on a pipe stalled on its own drain). const decompressed = await collectStream( new Response(data).body!.pipeThrough(createGzipDecoder()), MAX_DECOMPRESSED_BYTES, ); let count = 0; const entries = await unpackTar(decompressed, { strip: 0, filter: (header) => { if (header.type !== "file") return false; if (header.size > MAX_FILE_BYTES) return false; const name = header.name.replace(LEADING_DOT_SLASH, ""); const rel = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name; if (keep && !keep(rel)) return false; if (++count > MAX_TAR_FILES) throw new Error(`Archive has more than ${MAX_TAR_FILES} files`); return true; }, }); const files = new Map(); for (const e of entries) { if (!e.data || !e.header.name) continue; const name = e.header.name.replace(LEADING_DOT_SLASH, ""); files.set(name.includes("/") ? name.slice(name.indexOf("/") + 1) : name, e.data); } return files; }