import { createHash } from "node:crypto"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { gunzipSync } from "node:zlib"; import { validateBuildpackUri, type BuildArchitecture, } from "../shared/build-protocol"; export const BUILDPACK_LIMITS = { download: 64 * 1024 * 1024, expanded: 256 * 1024 * 1024, file: 32 * 1024 * 1024, entries: 10000, redirects: 5, timeoutMs: 60000, }; type Entry = { path: string; data: Buffer; mode: number; directory: boolean }; const hash = (data: Uint8Array) => `sha256:${createHash("sha256").update(data).digest("hex")}`; const fail = (message: string): never => { throw new Error(`Invalid buildpack package: ${message}`); }; /** Redirects are checked before any network request, with one deadline for the entire transfer. */ export type PackageFetcher = ( url: string, init?: RequestInit, ) => Promise; export async function fetchBuildpackPackage( uri: string, fetcher: PackageFetcher = fetch, ): Promise { validateBuildpackUri(uri); let url = new URL(uri); const pin = url.hash.slice("#sha256=".length); url.hash = ""; const signal = AbortSignal.timeout(BUILDPACK_LIMITS.timeoutMs); for ( let redirects = 0; redirects <= BUILDPACK_LIMITS.redirects; redirects++ ) { if ( url.protocol !== "https:" || url.username || url.password || url.port || !["github.com", "release-assets.githubusercontent.com"].includes( url.hostname, ) ) fail("redirect must remain on an allowed HTTPS release host"); const response = await fetcher(url.href, { redirect: "manual", signal, headers: { Accept: "application/octet-stream" }, }); if ([301, 302, 303, 307, 308].includes(response.status)) { await response.body?.cancel(); const location = response.headers.get("location"); if (!location || redirects === BUILDPACK_LIMITS.redirects) fail("redirect limit or missing Location"); url = new URL(location!, url); if (url.hash) fail("redirect fragment is unsupported"); continue; } if (!response.ok) { await response.body?.cancel(); fail(`download HTTP ${response.status}`); } const size = response.headers.get("content-length"); if ( size && (!/^\d+$/.test(size) || Number(size) > BUILDPACK_LIMITS.download) ) { await response.body?.cancel(); fail("download exceeds size limit"); } if (!response.body) fail("empty download"); const reader = response.body!.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { for (;;) { const { value, done } = await reader.read(); if (done) break; total += value.byteLength; if (total > BUILDPACK_LIMITS.download) fail("download exceeds size limit"); chunks.push(value); } } catch (error) { await reader.cancel().catch(() => {}); throw error; } finally { reader.releaseLock(); } const bytes = Buffer.concat(chunks, total); if (pin && hash(bytes) !== `sha256:${pin}`) fail("download SHA-256 mismatch"); return bytes; } return fail("redirect limit"); } function expand(bytes: Buffer): Buffer { if (bytes[0] === 0x1f && bytes[1] === 0x8b) return gunzipSync(bytes, { maxOutputLength: BUILDPACK_LIMITS.expanded }); if (bytes.length > BUILDPACK_LIMITS.expanded) fail("expanded size limit"); return bytes; } /** Only regular files/directories, strict ustar paths, no links/PAX/GNU extension interpretation. */ export function parseBuildpackTar( bytes: Buffer, kind: "oci" | "layer", ): Entry[] { bytes = expand(bytes); const entries: Entry[] = []; const seen = new Map(); const implicitDirectories = new Set(); const text = (b: Buffer) => b.toString("utf8").split("\0")[0]!; const octal = (b: Buffer) => { const value = text(b).trim(); if (!/^[0-7]+$/.test(value)) return fail("invalid tar numeric field"); const number = parseInt(value, 8); if (!Number.isSafeInteger(number)) fail("invalid tar numeric field"); return number; }; for (let offset = 0; offset + 512 <= bytes.length;) { const header = bytes.subarray(offset, offset + 512); if (header.every((byte) => byte === 0)) { if ( bytes.length - offset < 1024 || !bytes.subarray(offset).every((byte) => byte === 0) ) fail("invalid tar trailer"); return entries; } if (text(header.subarray(257, 263)) !== "ustar") fail("unsupported tar format"); const checksum = header.reduce( (sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0, ); if (checksum !== octal(header.subarray(148, 156))) fail("tar checksum mismatch"); const type = header[156]; if (![0, 48, 53].includes(type!)) fail("tar links and special entries are unsupported"); const directory = type === 53; const prefix = text(header.subarray(345, 500)); let path = `${prefix ? `${prefix}/` : ""}${text(header.subarray(0, 100))}`; // pack emits these known absolute OCI/CNB roots. No general absolute-path stripping. if ( kind === "oci" && /^\/(?:blobs(?:\/|$)|index\.json$|oci-layout$)/.test(path) ) path = path.slice(1); if ( kind === "layer" && (path.startsWith("/cnb/buildpacks/") || (directory && ["/cnb", "/cnb/", "/cnb/buildpacks"].includes(path))) ) path = path.slice(1); if (path.startsWith("./")) path = path.slice(2); if (directory && path.endsWith("/")) path = path.slice(0, -1); if ( !path || path.includes("\\") || [...path].some( (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127, ) || path.split("/").some((part) => !part || part === "." || part === "..") || /^[A-Za-z]:/.test(path) ) fail("unsafe tar path"); if (seen.has(path)) fail("duplicate tar path"); for (let parent = dirname(path); parent !== "."; parent = dirname(parent)) { if (seen.get(parent) === false) fail("tar file used as directory"); implicitDirectories.add(parent); } if (!directory && implicitDirectories.has(path)) fail("tar directory replaced by file"); const size = octal(header.subarray(124, 136)); if (size > BUILDPACK_LIMITS.file || (directory && size !== 0)) fail("tar file size limit"); const end = offset + 512 + Math.ceil(size / 512) * 512; if (end > bytes.length) fail("truncated tar entry"); entries.push({ path, directory, mode: octal(header.subarray(100, 108)) & 0o111 ? 0o755 : 0o644, data: bytes.subarray(offset + 512, offset + 512 + size), }); seen.set(path, directory); if (entries.length > BUILDPACK_LIMITS.entries) fail("tar entry limit"); offset = end; } return fail("missing tar trailer"); } function json(data: Uint8Array): any { if (data.byteLength > 1024 * 1024) fail("JSON metadata size limit"); try { const value = JSON.parse(Buffer.from(data).toString()); if (!value || typeof value !== "object" || Array.isArray(value)) fail("expected JSON metadata object"); return value; } catch { return fail("invalid JSON metadata"); } } /** Validate the complete OCI descriptor chain before writing any package files. */ export function unpackBuildpackPackage( bytes: Buffer, architecture: BuildArchitecture, ): { id: string; version: string; entries: Entry[] } { const outer = parseBuildpackTar(bytes, "oci"); if ( outer.some( (entry) => !/^(?:blobs(?:\/sha256(?:\/[a-f0-9]{64})?)?|index\.json|oci-layout)$/.test( entry.path, ), ) ) fail("unexpected OCI path"); const files = new Map( outer .filter((entry) => !entry.directory) .map((entry) => [entry.path, entry.data]), ); const required = (path: string) => files.get(path) ?? fail(`missing ${path}`); if (json(required("oci-layout")).imageLayoutVersion !== "1.0.0") fail("unsupported OCI layout"); const blob = (descriptor: any, media: string[]) => { if ( !descriptor || !/^sha256:[a-f0-9]{64}$/.test(descriptor.digest) || !Number.isSafeInteger(descriptor.size) || descriptor.size < 0 || !media.includes(descriptor.mediaType) ) fail("invalid OCI descriptor"); const data = required(`blobs/sha256/${descriptor.digest.slice(7)}`); if (data.length !== descriptor.size || hash(data) !== descriptor.digest) fail("OCI descriptor size/digest mismatch"); return data; }; const index = json(required("index.json")); if ( index.schemaVersion !== 2 || !Array.isArray(index.manifests) || index.manifests.length !== 1 ) fail("expected one OCI image manifest"); const manifest = json( blob(index.manifests[0], [ "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json", ]), ); if ( manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1 ) fail("only single-buildpack, single-layer packages are supported"); const config = json( blob(manifest.config, [ "application/vnd.oci.image.config.v1+json", "application/vnd.docker.container.image.v1+json", ]), ); if (config.os !== "linux" || config.architecture !== architecture) fail( `package target ${config.os}/${config.architecture} does not match linux/${architecture}`, ); const labels = config.config?.Labels; const metadata = json( Buffer.from(labels?.["io.buildpacks.buildpackage.metadata"] ?? ""), ); const { id, version } = metadata; if ( typeof id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9./-]*$/.test(id) || id.split("/").some((part) => !part || part === "." || part === "..") || typeof version !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9.+-]*$/.test(version) ) fail("invalid buildpack ID/version"); const layers = json( Buffer.from(labels?.["io.buildpacks.buildpack.layers"] ?? ""), ); if ( Object.keys(layers).length !== 1 || Object.keys(layers[id] ?? {}).length !== 1 || !layers[id]?.[version] ) fail("dependencies/composite packages are unsupported"); const layerMetadata = layers[id][version]; const layer = expand( blob(manifest.layers[0], [ "application/vnd.oci.image.layer.v1.tar", "application/vnd.oci.image.layer.v1.tar+gzip", "application/vnd.docker.image.rootfs.diff.tar.gzip", ]), ); if ( config.rootfs?.type !== "layers" || config.rootfs.diff_ids?.length !== 1 || hash(layer) !== config.rootfs.diff_ids[0] || hash(layer) !== layerMetadata.layerDiffID ) fail("layer diff digest mismatch"); const root = `cnb/buildpacks/${id.replaceAll("/", "_")}/${version}`; const entries = parseBuildpackTar(layer, "layer"); if ( entries.some( (entry) => entry.path !== root && !entry.path.startsWith(`${root}/`) && !(entry.directory && root.startsWith(`${entry.path}/`)), ) ) fail("layer contains files outside the declared buildpack"); const descriptor = entries.find( (entry) => entry.path === `${root}/buildpack.toml` && !entry.directory, ); if (!descriptor || descriptor.data.length > 1024 * 1024) fail("missing buildpack.toml"); let toml: any; try { toml = Bun.TOML.parse(descriptor!.data.toString()); } catch { return fail("invalid buildpack.toml (requires Bun TOML support)"); } if ( toml.buildpack?.id !== id || toml.buildpack?.version !== version || toml.api !== layerMetadata.api ) fail("buildpack descriptor does not match config metadata"); if (toml.api !== "0.10") fail("only Buildpack API 0.10 is currently supported"); if (toml.order || toml.buildpack?.extensions) fail("composite/extension buildpacks are unsupported"); for (const stacks of [metadata.stacks, layerMetadata.stacks, toml.stacks]) if ( stacks !== undefined && (!Array.isArray(stacks) || !stacks.some((stack: any) => stack.id === "*")) ) fail("stack-specific buildpacks are unsupported"); for (const targets of [ metadata.targets, toml.targets, layerMetadata.targets, ]) { if ( Array.isArray(targets) && targets.some((target: any) => target.distros?.length || target.variant) ) fail("distro/variant-specific buildpacks are unsupported"); if ( targets !== undefined && (!Array.isArray(targets) || !targets.some( (target: any) => target.os === "linux" && (!target.arch || target.arch === architecture), )) ) fail(`buildpack targets do not support linux/${architecture}`); } for (const bin of ["detect", "build"]) { if ( !entries.some( (entry) => entry.path === `${root}/bin/${bin}` && !entry.directory && entry.mode === 0o755, ) ) fail(`missing executable bin/${bin}`); } return { id, version, entries: entries .filter( (entry) => entry.path.startsWith(`${root}/`) || entry.path === root, ) .map((entry) => ({ ...entry, path: entry.path.slice("cnb/buildpacks/".length), })), }; } export async function stageBuildpackPackage( uri: string, architecture: BuildArchitecture, destination: string, fetcher?: PackageFetcher, ): Promise { const pkg = unpackBuildpackPackage( await fetchBuildpackPackage(uri, fetcher), architecture, ); await rm(destination, { recursive: true, force: true }); try { await mkdir(join(destination, "buildpacks"), { recursive: true, mode: 0o755, }); for (const entry of pkg.entries) { const path = join(destination, "buildpacks", entry.path); if (entry.directory) await mkdir(path, { recursive: true, mode: 0o755 }); else { await mkdir(dirname(path), { recursive: true, mode: 0o755 }); await writeFile(path, entry.data, { mode: entry.mode, flag: "wx" }); } } await writeFile( join(destination, "order.toml"), `[[order]]\n[[order.group]]\nid = ${JSON.stringify(pkg.id)}\nversion = ${JSON.stringify(pkg.version)}\n`, { mode: 0o644, flag: "wx" }, ); } catch (error) { await rm(destination, { recursive: true, force: true }); throw error; } }