437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
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<Response>;
|
|
export async function fetchBuildpackPackage(
|
|
uri: string,
|
|
fetcher: PackageFetcher = fetch,
|
|
): Promise<Buffer> {
|
|
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<string, boolean>();
|
|
const implicitDirectories = new Set<string>();
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|