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://user@github.com/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:pass@github.com/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); }); });