Files
kuber/tests/server/buildpack-package.test.ts
T
2026-10-06 15:31:51 +00:00

376 lines
11 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import {
BUILDPACK_LIMITS,
fetchBuildpackPackage,
parseBuildpackTar,
unpackBuildpackPackage,
stageBuildpackPackage,
type PackageFetcher,
} from "../../server/buildpack-package";
import { validateBuildpackUri } from "../../shared/build-protocol";
const uri = "https://github.com/example/bun/releases/download/v1/buildpack.cnb";
const digest = (bytes: Uint8Array) =>
`sha256:${createHash("sha256").update(bytes).digest("hex")}`;
function tar(
files: Array<{ path: string; data?: Buffer; type?: string; mode?: number }>,
): Buffer {
const chunks: Buffer[] = [];
for (const file of files) {
const data = file.data ?? Buffer.alloc(0);
const header = Buffer.alloc(512);
header.write(file.path, 0, 100);
header.write((file.mode ?? 0o644).toString(8).padStart(7, "0") + "\0", 100);
header.write(data.length.toString(8).padStart(11, "0") + "\0", 124);
header.fill(32, 148, 156);
header.write(file.type ?? "0", 156);
header.write("ustar\0", 257);
const sum = header.reduce((a, b) => a + b, 0);
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
chunks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
}
return Buffer.concat([...chunks, Buffer.alloc(1024)]);
}
export function syntheticPackage(
options: {
arch?: string;
mismatch?: boolean;
composite?: boolean;
corrupt?: boolean;
badDiff?: boolean;
targets?: string;
api?: string;
} = {},
): Buffer {
const api = options.api ?? "0.10";
const root = "/cnb/buildpacks/example_bun/1.0.0";
const layer = tar([
{
path: `${root}/buildpack.toml`,
data: Buffer.from(
`api = "${api}"\n[buildpack]\nid = "${options.mismatch ? "wrong" : "example/bun"}"\nversion = "1.0.0"\n${options.composite ? '[[order]]\n[[order.group]]\nid = "dependency"\n' : ""}${options.targets ?? ""}`,
),
},
{
path: `${root}/bin/detect`,
data: Buffer.from("#!/bin/sh\nexit 0\n"),
mode: 0o755,
},
{
path: `${root}/bin/build`,
data: Buffer.from("#!/bin/sh\nexit 0\n"),
mode: 0o755,
},
]);
const compressed = gzipSync(layer);
const metadata = {
id: "example/bun",
version: "1.0.0",
stacks: [{ id: "*" }],
};
const config = Buffer.from(
JSON.stringify({
architecture: options.arch ?? "arm64",
os: "linux",
rootfs: {
type: "layers",
diff_ids: [
options.badDiff ? `sha256:${"0".repeat(64)}` : digest(layer),
],
},
config: {
Labels: {
"io.buildpacks.buildpackage.metadata": JSON.stringify(metadata),
"io.buildpacks.buildpack.layers": JSON.stringify({
"example/bun": {
"1.0.0": {
api,
layerDiffID: digest(layer),
targets: [{ os: "linux", arch: options.arch ?? "arm64" }],
},
},
}),
},
},
}),
);
const descriptor = (bytes: Buffer, mediaType: string) => ({
mediaType,
digest: digest(bytes),
size: bytes.length,
});
const manifest = Buffer.from(
JSON.stringify({
schemaVersion: 2,
config: descriptor(config, "application/vnd.oci.image.config.v1+json"),
layers: [
descriptor(compressed, "application/vnd.oci.image.layer.v1.tar+gzip"),
],
}),
);
return tar([
{
path: "/oci-layout",
data: Buffer.from('{"imageLayoutVersion":"1.0.0"}'),
},
{
path: "/index.json",
data: Buffer.from(
JSON.stringify({
schemaVersion: 2,
manifests: [
descriptor(manifest, "application/vnd.oci.image.manifest.v1+json"),
],
}),
),
},
...[config, manifest, compressed].map((data) => ({
path: `/blobs/sha256/${digest(data).slice(7)}`,
data:
options.corrupt && data === config
? Buffer.from("x".repeat(data.length))
: data,
})),
]);
}
describe("buildpack packages", () => {
test("stages escaped ID with executable files and explicit order outside source", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-package-"));
try {
const destination = join(root, "package");
await stageBuildpackPackage(
uri,
"arm64",
destination,
async () => new Response(syntheticPackage()),
);
expect(await readFile(join(destination, "order.toml"), "utf8")).toBe(
'[[order]]\n[[order.group]]\nid = "example/bun"\nversion = "1.0.0"\n',
);
expect(
(
await stat(
join(destination, "buildpacks/example_bun/1.0.0/bin/build"),
)
).mode & 0o777,
).toBe(0o755);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("accepts known absolute OCI/CNB prefixes, verified config and gzip layers", () => {
const pkg = unpackBuildpackPackage(gzipSync(syntheticPackage()), "arm64");
expect(pkg.id).toBe("example/bun");
expect(pkg.version).toBe("1.0.0");
expect(pkg.entries.map((entry) => entry.path)).toContain(
"example_bun/1.0.0/bin/build",
);
});
test.each(["null", "[]", '"layout"', "{"])(
"rejects malformed OCI metadata %s",
(value) => {
expect(() =>
unpackBuildpackPackage(
tar([{ path: "oci-layout", data: Buffer.from(value) }]),
"arm64",
),
).toThrow(/JSON metadata/);
},
);
test("rejects an OCI index with a non-array manifest list", () => {
expect(() =>
unpackBuildpackPackage(
tar([
{
path: "oci-layout",
data: Buffer.from('{"imageLayoutVersion":"1.0.0"}'),
},
{
path: "index.json",
data: Buffer.from('{"schemaVersion":2,"manifests":{"length":1}}'),
},
]),
"arm64",
),
).toThrow(/one OCI image manifest/);
});
test.each([
[{ arch: "arm64" }, "amd64", /target/],
[{ mismatch: true }, "arm64", /descriptor/],
[{ corrupt: true }, "arm64", /digest/],
[{ badDiff: true }, "arm64", /diff digest/],
[{ composite: true }, "arm64", /composite/],
[{ api: "0.99" }, "arm64", /API/],
[
{ targets: '[[targets]]\nos = "linux"\narch = "amd64"\n' },
"arm64",
/targets/,
],
] as const)(
"rejects incompatible/invalid package %#",
(options, arch, error) => {
expect(() =>
unpackBuildpackPackage(syntheticPackage(options), arch),
).toThrow(error);
},
);
test.each([
"../escape",
"/etc/passwd",
"/blobs/../escape",
"a//b",
"a/./b",
"a\\b",
"C:/x",
])("rejects tar traversal %s", (path) => {
expect(() => parseBuildpackTar(tar([{ path }]), "oci")).toThrow(/unsafe/);
});
test.each(["1", "2", "3", "4", "6", "x", "g", "L"])(
"rejects links/devices/extensions %s",
(type) => {
expect(() =>
parseBuildpackTar(tar([{ path: "x", type }]), "oci"),
).toThrow(/unsupported/);
},
);
test("rejects checksum, truncation, duplicate paths and file/directory collisions", () => {
const bytes = tar([{ path: "x", data: Buffer.from("hello") }]);
bytes[0] = 121;
expect(() => parseBuildpackTar(bytes, "oci")).toThrow(/checksum/);
expect(() =>
parseBuildpackTar(
tar([{ path: "x", data: Buffer.from("x") }]).subarray(0, 513),
"oci",
),
).toThrow(/truncated/);
expect(() =>
parseBuildpackTar(tar([{ path: "x" }, { path: "x" }]), "oci"),
).toThrow(/duplicate/);
expect(() =>
parseBuildpackTar(tar([{ path: "x" }, { path: "x/y" }]), "oci"),
).toThrow(/directory/);
});
test("bounds entries, file sizes and decompression", () => {
expect(() =>
parseBuildpackTar(
tar(
Array.from({ length: BUILDPACK_LIMITS.entries + 1 }, (_, index) => ({
path: String(index),
})),
),
"oci",
),
).toThrow(/entry limit/);
expect(() =>
parseBuildpackTar(
tar([{ path: "x", data: Buffer.alloc(BUILDPACK_LIMITS.file + 1) }]),
"oci",
),
).toThrow(/file size/);
expect(() =>
parseBuildpackTar(
gzipSync(Buffer.alloc(BUILDPACK_LIMITS.expanded + 1), { level: 1 }),
"oci",
),
).toThrow();
});
test.each([
"http://github.com/a/b/releases/download/v1/x.cnb",
"https://[email protected]/a/b/releases/download/v1/x.cnb",
"https://github.com.evil.test/a/b/releases/download/v1/x.cnb",
"https://127.0.0.1/x.cnb",
"https://github.com/a/b/blob/x.cnb",
uri + "#other",
uri + "?token=x",
"file:///x.cnb",
])("rejects URI %s", (value) => {
expect(() => validateBuildpackUri(value)).toThrow();
});
test("manually follows the release asset redirect and verifies optional pin", async () => {
const bytes = syntheticPackage();
const calls: string[] = [];
const fetcher = (async (url: any, init: any) => {
calls.push(String(url));
expect(init.redirect).toBe("manual");
return calls.length === 1
? new Response(null, {
status: 302,
headers: {
location:
"https://release-assets.githubusercontent.com/asset?signature=x",
},
})
: new Response(bytes);
}) as PackageFetcher;
expect(
await fetchBuildpackPackage(
`${uri}#sha256=${digest(bytes).slice(7)}`,
fetcher,
),
).toEqual(bytes);
expect(calls).toHaveLength(2);
await expect(
fetchBuildpackPackage(
`${uri}#sha256=${"0".repeat(64)}`,
async () => new Response(bytes),
),
).rejects.toThrow(/SHA-256/);
});
test.each([
"http://github.com/x",
"https://release-assets.githubusercontent.com.evil.test/x",
"https://localhost/x",
"https://user:[email protected]/x",
"https://github.com:444/x",
])("rejects redirect before fetching %s", async (location) => {
let calls = 0;
await expect(
fetchBuildpackPackage(uri, async () => {
calls++;
return new Response(null, { status: 302, headers: { location } });
}),
).rejects.toThrow(/redirect/);
expect(calls).toBe(1);
});
test("bounds redirect count and advertised/streamed download sizes", async () => {
await expect(
fetchBuildpackPackage(
uri,
async () =>
new Response(null, { status: 302, headers: { location: uri } }),
),
).rejects.toThrow(/redirect limit/);
await expect(
fetchBuildpackPackage(
uri,
async () =>
new Response("", {
headers: {
"content-length": String(BUILDPACK_LIMITS.download + 1),
},
}),
),
).rejects.toThrow(/size limit/);
let cancelled = false;
const stream = new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
});
await expect(
fetchBuildpackPackage(uri, async () => new Response(stream)),
).rejects.toThrow(/size limit/);
expect(cancelled).toBe(true);
});
});