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