import { createHash } from "node:crypto"; import { chmod, link, mkdir, symlink, writeFile } from "node:fs/promises"; import { dirname, join, posix } from "node:path"; import { gunzipSync } from "node:zlib"; export const MAX_PREPARED_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; const MAX_ARCHIVE_FILES = 40_000; export function sha256(bytes: Uint8Array | string): string { return createHash("sha256").update(bytes).digest("hex"); } function fail(message: string): never { throw new Error(`invalid_environment_artifact: ${message}`); } export async function fetchEnvironmentBytes( url: string, limit: number, expectedHash?: string, ): Promise { const parsed = new URL(url); if ( parsed.protocol !== "https:" && !( parsed.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname) ) ) fail("Artifact URL must use HTTPS."); const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(120_000), }); if (!response.ok || !response.body) fail( "Artifact download failed; retry preparation, not the business operation.", ); const declared = Number(response.headers.get("content-length") ?? 0); if (declared > limit) fail("Artifact exceeds the download limit."); const chunks: Buffer[] = []; let size = 0; const reader = response.body.getReader(); try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > limit) { await reader.cancel(); fail("Artifact exceeds the download limit."); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); } const bytes = Buffer.concat(chunks); if (expectedHash && sha256(bytes) !== expectedHash) fail("Artifact checksum does not match its saved revision."); return bytes; } function tarString(header: Buffer, start: number, length: number): string { const bytes = header.subarray(start, start + length); return bytes .subarray(0, bytes.indexOf(0) < 0 ? bytes.length : bytes.indexOf(0)) .toString("utf8"); } function tarNumber(header: Buffer, start: number, length: number): number { const value = tarString(header, start, length).trim(); if (value && !/^[0-7]+$/.test(value)) fail("Unsupported tar numeric encoding."); const number = value ? Number.parseInt(value, 8) : 0; if (!Number.isSafeInteger(number) || number < 0) fail("Invalid tar size."); return number; } function archivePath(raw: string): string { const path = raw.replace(/^(\.\/)+/, "").replace(/\/$/, ""); if ( !path || path.length > 1024 || path.startsWith("/") || path.includes("\\") || [...path].some((char) => char.charCodeAt(0) < 32) || path.split("/").some((p) => !p || p === "." || p === "..") ) fail("Archive contains an unsafe path."); return path; } function parsePax(data: Buffer): Record { const output: Record = {}; let offset = 0; while (offset < data.length) { const space = data.indexOf(32, offset); if (space < 0) fail("Invalid tar metadata."); const rawLength = data.subarray(offset, space).toString("ascii"); const length = Number(rawLength); if ( !/^\d+$/.test(rawLength) || length <= space - offset + 1 || offset + length > data.length ) fail("Invalid tar metadata length."); const item = data.subarray(space + 1, offset + length).toString("utf8"); const equals = item.indexOf("="); if (equals < 1 || !item.endsWith("\n")) fail("Invalid tar metadata value."); const key = item.slice(0, equals); if (key.startsWith("GNU.sparse") || key === "size") fail("Sparse or extended-size archives are unsupported."); output[key] = item.slice(equals + 1, -1); offset += length; } return output; } type Entry = { path: string; kind: "file" | "dir" | "symlink" | "hardlink"; mode: number; content: Buffer; target?: string; }; /** Validate the entire archive before writing anything, including every link ancestor. */ export function inspectPreparedArchive(compressed: Buffer): Entry[] { if (compressed.length > MAX_PREPARED_ARCHIVE_BYTES) fail("Compressed archive is too large."); const bytes = gunzipSync(compressed, { maxOutputLength: MAX_UNPACKED_BYTES }); const entries: Entry[] = []; const seen = new Map(); let pax: Record = {}; let longName: string | undefined; let longLink: string | undefined; for (let offset = 0; offset + 512 <= bytes.length; ) { const header = bytes.subarray(offset, offset + 512); if (header.every((value) => value === 0)) break; let checksum = 0; for (let i = 0; i < 512; i++) checksum += i >= 148 && i < 156 ? 32 : header[i]; if (checksum !== tarNumber(header, 148, 8)) fail("Invalid tar header checksum."); const size = tarNumber(header, 124, 12); if (offset + 512 + size > bytes.length) fail("Truncated archive."); const content = bytes.subarray(offset + 512, offset + 512 + size); offset += 512 + Math.ceil(size / 512) * 512; const type = tarString(header, 156, 1); if (type === "x") { pax = parsePax(content); continue; } if (type === "L" || type === "K") { const value = content .toString("utf8") .replace(/\0.*$/, "") .replace(/\n$/, ""); if (type === "L") longName = value; else longLink = value; continue; } if (type === "g") fail("Global tar metadata is unsupported."); const prefix = tarString(header, 345, 155); const rawPath = pax.path ?? longName ?? [prefix, tarString(header, 0, 100)].filter(Boolean).join("/"); const rawTarget = pax.linkpath ?? longLink ?? tarString(header, 157, 100); pax = {}; longName = undefined; longLink = undefined; if ((rawPath === "." || rawPath === "./") && type === "5") continue; const path = archivePath(rawPath); const kind = type === "" || type === "0" ? "file" : type === "5" ? "dir" : type === "2" ? "symlink" : type === "1" ? "hardlink" : undefined; if (!kind) fail("Only files, directories and internal links are supported."); if (seen.has(path)) fail("Duplicate archive path."); const mode = kind === "dir" || tarNumber(header, 100, 8) & 0o111 ? 0o755 : 0o644; const entry: Entry = { path, kind, mode, content }; if (kind === "symlink") { if ( !rawTarget || rawTarget.startsWith("/") || rawTarget.includes("\\") || [...rawTarget].some((char) => char.charCodeAt(0) < 32) ) fail("Archive symlink must stay inside its environment."); const destination = posix.normalize( posix.join(posix.dirname(path), rawTarget), ); if (destination === ".." || destination.startsWith("../")) fail("Archive symlink escapes its environment."); entry.target = rawTarget; } else if (kind === "hardlink") entry.target = archivePath(rawTarget); entries.push(entry); seen.set(path, entry); if (entries.length > MAX_ARCHIVE_FILES) fail("Archive has too many files."); } if (pax.path || longName || longLink) fail("Archive ended in incomplete metadata."); for (const entry of entries) { for ( let parent = posix.dirname(entry.path); parent !== "."; parent = posix.dirname(parent) ) { const ancestor = seen.get(parent); if (ancestor && ancestor.kind !== "dir") fail("Archive path traverses a link or file."); } if ( entry.kind === "hardlink" && (!entry.target || seen.get(entry.target)?.kind !== "file") ) fail("Hard links must refer to a regular archive file."); } return entries; } export async function extractPreparedArchive( compressed: Buffer, destination: string, ): Promise { const entries = inspectPreparedArchive(compressed); await mkdir(destination, { recursive: true, mode: 0o700 }); for (const entry of entries.filter( (entry) => entry.kind === "dir" || entry.kind === "file", )) { const path = join(destination, entry.path); await mkdir(dirname(path), { recursive: true, mode: 0o755 }); if (entry.kind === "dir") await mkdir(path, { recursive: true, mode: entry.mode }); else { await writeFile(path, entry.content, { mode: entry.mode, flag: "wx" }); await chmod(path, entry.mode); } } for (const entry of entries.filter( (entry) => entry.kind === "symlink" || entry.kind === "hardlink", )) { const path = join(destination, entry.path); await mkdir(dirname(path), { recursive: true, mode: 0o755 }); if (!entry.target) fail("Archive link has no destination."); if (entry.kind === "symlink") await symlink(entry.target, path); else await link(join(destination, entry.target), path); } }