feat: release 2.7.0-rc2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user