feat: release 2.7.0-rc2

This commit is contained in:
2026-10-06 15:31:51 +00:00 Unverified
parent cd9fb512cd
commit c8de9ddcba
42 changed files with 6127 additions and 593 deletions
+436
View File
@@ -0,0 +1,436 @@
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;
}
}