376 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|