292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { execFile } from "node:child_process";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readlink,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import {
|
|
enumerateWorkspace,
|
|
materializeWorkspace,
|
|
serializeWorkspaceManifest,
|
|
validateWorkspaceManifest,
|
|
validateWorkspacePath,
|
|
workspaceManifestDigest,
|
|
} from "../../lib/workspace";
|
|
import {
|
|
BUILD_PROTOCOL_VERSION,
|
|
type WorkspaceManifest,
|
|
} from "../../shared/build-protocol";
|
|
|
|
const run = promisify(execFile);
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
async function temporaryDirectory(prefix: string): Promise<string> {
|
|
const path = await mkdtemp(join(tmpdir(), prefix));
|
|
temporaryDirectories.push(path);
|
|
return path;
|
|
}
|
|
|
|
async function repository(): Promise<string> {
|
|
const root = await temporaryDirectory("kuber-workspace-");
|
|
await run("git", ["init", "-q", root]);
|
|
await run("git", ["-C", root, "config", "user.email", "[email protected]"]);
|
|
await run("git", ["-C", root, "config", "user.name", "Test"]);
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((path) => rm(path, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
describe("workspace snapshots", () => {
|
|
test("snapshots Gitless directories deterministically without secrets", async () => {
|
|
const root = await temporaryDirectory("kuber-workspace-filesystem-");
|
|
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
|
|
await writeFile(join(root, "app.txt"), "application");
|
|
await writeFile(join(root, ".env.local"), "SECRET=hidden");
|
|
await writeFile(join(root, "db_credentials.json"), "hidden");
|
|
|
|
const first = await enumerateWorkspace(root);
|
|
const second = await enumerateWorkspace(root);
|
|
expect(first).toEqual(second);
|
|
expect(first.manifest.files.map((file) => file.path)).toEqual([
|
|
"Dockerfile",
|
|
"app.txt",
|
|
]);
|
|
});
|
|
|
|
test("works without Git and conservatively prunes ignored/generated and credential files", async () => {
|
|
const root = await temporaryDirectory("kuber-workspace-no-git-");
|
|
await writeFile(join(root, ".gitignore"), "local-only/\n*.generated\nsecrets/\n!secrets/keep.txt\n");
|
|
await writeFile(join(root, "app.ts"), "source");
|
|
await mkdir(join(root, "local-only"));
|
|
await writeFile(join(root, "local-only", "hidden"), "secret");
|
|
await writeFile(join(root, "cache.generated"), "generated");
|
|
await writeFile(join(root, "credentials.json"), "credential");
|
|
await writeFile(join(root, ".env.local"), "SECRET=hidden");
|
|
await mkdir(join(root, "secrets"));
|
|
await writeFile(join(root, "secrets/keep.txt"), "must remain excluded");
|
|
await mkdir(join(root, "services/secrets"), { recursive: true });
|
|
await writeFile(join(root, "services/secrets/production.yaml"), "secret");
|
|
await mkdir(join(root, "app_credentials"));
|
|
await writeFile(join(root, "app_credentials/key.json"), "credential");
|
|
await mkdir(join(root, "config"));
|
|
await writeFile(join(root, "config/private.yaml"), "config secret");
|
|
await mkdir(join(root, "secretary"));
|
|
await writeFile(join(root, "secretary/notes.txt"), "ordinary directory");
|
|
await mkdir(join(root, ".next"));
|
|
await writeFile(join(root, ".next/generated"), "generated");
|
|
const previousPath = process.env.PATH;
|
|
try {
|
|
process.env.PATH = "";
|
|
const snapshot = await enumerateWorkspace(root);
|
|
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
|
|
".gitignore",
|
|
"app.ts",
|
|
"secretary/notes.txt",
|
|
]);
|
|
} finally {
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
}
|
|
});
|
|
|
|
test("uses conservative filesystem mode for an initialized repo when Git is absent from PATH", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, ".gitignore"), "secrets/\n!secrets/keep.txt\n");
|
|
await writeFile(join(root, "source.ts"), "source");
|
|
await writeFile(join(root, ".env.local"), "SECRET=hidden");
|
|
await mkdir(join(root, "secrets"));
|
|
await writeFile(join(root, "secrets/keep.txt"), "hidden");
|
|
const previousPath = process.env.PATH;
|
|
try {
|
|
process.env.PATH = "";
|
|
const first = await enumerateWorkspace(root);
|
|
const second = await enumerateWorkspace(root);
|
|
expect(first).toEqual(second);
|
|
expect(first.manifest.files.map((file) => file.path)).toEqual([
|
|
".gitignore", "source.ts",
|
|
]);
|
|
} finally {
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
}
|
|
});
|
|
|
|
test("captures working tracked, untracked, and ignored dotenv files deterministically", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, ".gitignore"), "ignored*\n.env*\nsub/.env*\n");
|
|
await writeFile(join(root, "tracked.txt"), "committed");
|
|
await writeFile(join(root, "script.sh"), "#!/bin/sh\n");
|
|
await run("chmod", ["755", join(root, "script.sh")]);
|
|
await run("git", [
|
|
"-C",
|
|
root,
|
|
"add",
|
|
".gitignore",
|
|
"tracked.txt",
|
|
"script.sh",
|
|
]);
|
|
await run("git", ["-C", root, "commit", "-qm", "initial"]);
|
|
|
|
await writeFile(join(root, "tracked.txt"), "working tree");
|
|
await writeFile(join(root, "untracked.txt"), "untracked");
|
|
await writeFile(join(root, "ignored.bin"), "excluded");
|
|
await writeFile(join(root, ".env.local"), "SECRET=root");
|
|
await run("mkdir", [join(root, "sub")]);
|
|
await writeFile(join(root, "sub/.env.test"), "SECRET=sub");
|
|
await run("ln", ["-s", "tracked.txt", join(root, "link")]);
|
|
|
|
const first = await enumerateWorkspace(root);
|
|
const second = await enumerateWorkspace(root);
|
|
expect(first).toEqual(second);
|
|
expect(first.manifest.files.map((file) => file.path)).toEqual([
|
|
".env.local",
|
|
".gitignore",
|
|
"link",
|
|
"script.sh",
|
|
"sub/.env.test",
|
|
"tracked.txt",
|
|
"untracked.txt",
|
|
]);
|
|
expect(
|
|
first.manifest.files.find((file) => file.path === "script.sh")?.mode,
|
|
).toBe(0o755);
|
|
expect(
|
|
first.manifest.files.find((file) => file.path === "link")?.type,
|
|
).toBe("symlink");
|
|
expect(
|
|
first.manifest.files.some((file) => file.path === "ignored.bin"),
|
|
).toBe(false);
|
|
|
|
const destination = join(
|
|
await temporaryDirectory("kuber-materialized-parent-"),
|
|
"tree",
|
|
);
|
|
const blobs = new Map(first.blobs.map((blob) => [blob.digest, blob.data]));
|
|
await materializeWorkspace(destination, first.manifest, async (digest) =>
|
|
blobs.get(digest)!,
|
|
);
|
|
expect(await readFile(join(destination, "tracked.txt"), "utf8")).toBe(
|
|
"working tree",
|
|
);
|
|
expect(await readFile(join(destination, ".env.local"), "utf8")).toBe(
|
|
"SECRET=root",
|
|
);
|
|
expect(await readlink(join(destination, "link"))).toBe("tracked.txt");
|
|
expect((await stat(join(destination, "script.sh"))).mode & 0o777).toBe(
|
|
0o755,
|
|
);
|
|
});
|
|
|
|
test("omits tracked files deleted in the worktree", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, "deleted"), "value");
|
|
await run("git", ["-C", root, "add", "deleted"]);
|
|
await run("git", ["-C", root, "commit", "-qm", "initial"]);
|
|
await rm(join(root, "deleted"));
|
|
expect((await enumerateWorkspace(root)).manifest.files).toEqual([]);
|
|
});
|
|
|
|
test("rejects escaping symlinks and special files", async () => {
|
|
const symlinkRoot = await repository();
|
|
await run("ln", ["-s", "../outside", join(symlinkRoot, "escape")]);
|
|
await expect(enumerateWorkspace(symlinkRoot)).rejects.toThrow(
|
|
"Symlink escapes workspace",
|
|
);
|
|
|
|
const specialRoot = await repository();
|
|
await run("mkfifo", [join(specialRoot, "pipe")]);
|
|
await expect(enumerateWorkspace(specialRoot)).rejects.toThrow(
|
|
"Special files",
|
|
);
|
|
});
|
|
|
|
test("allows ignored special files and prunes ignored directories", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, ".gitignore"), "ignored-pipe\nignored-dir/\n");
|
|
await run("mkfifo", [join(root, "ignored-pipe")]);
|
|
await run("mkdir", [join(root, "ignored-dir")]);
|
|
await run("mkfifo", [join(root, "ignored-dir/pipe")]);
|
|
|
|
await expect(enumerateWorkspace(root)).resolves.toMatchObject({
|
|
manifest: { files: [{ path: ".gitignore" }] },
|
|
});
|
|
});
|
|
|
|
test("protects dotenv special files even when ignored", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, ".gitignore"), "*.pipe\n!keep.pipe\nsub/\n");
|
|
await run("mkfifo", [join(root, "blocked.pipe")]);
|
|
await expect(enumerateWorkspace(root)).resolves.toBeDefined();
|
|
|
|
await run("mkfifo", [join(root, ".env.pipe")]);
|
|
await expect(enumerateWorkspace(root)).rejects.toThrow(
|
|
"Special files are not allowed in workspaces: .env.pipe",
|
|
);
|
|
});
|
|
|
|
test("rejects traversal, unsorted manifests, ancestor collisions, and corrupt blobs", async () => {
|
|
expect(() => validateWorkspacePath("../secret")).toThrow(
|
|
"Unsafe workspace path",
|
|
);
|
|
const digest = `sha256:${"a".repeat(64)}` as const;
|
|
const unsorted: WorkspaceManifest = {
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
files: [
|
|
{ path: "b", type: "file", digest, size: 0, mode: 0o644 },
|
|
{ path: "a", type: "file", digest, size: 0, mode: 0o644 },
|
|
],
|
|
};
|
|
expect(() => validateWorkspaceManifest(unsorted)).toThrow(
|
|
"bytewise sorted",
|
|
);
|
|
const canonical = {
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
files: [{ path: "a", type: "file", digest, size: 0, mode: 0o644 }],
|
|
} satisfies WorkspaceManifest;
|
|
const reordered = JSON.parse(
|
|
`{"files":[{"mode":420,"size":0,"digest":"${digest}","type":"file","path":"a"}],"version":1}`,
|
|
) as WorkspaceManifest;
|
|
expect(workspaceManifestDigest(reordered)).toBe(
|
|
workspaceManifestDigest(canonical),
|
|
);
|
|
expect(
|
|
JSON.parse(Buffer.from(serializeWorkspaceManifest(reordered)).toString()),
|
|
).toEqual(canonical);
|
|
expect(() =>
|
|
validateWorkspaceManifest({
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
files: [
|
|
{ path: "a", type: "symlink", digest, size: 0, mode: 0o777 },
|
|
{ path: "a/b", type: "file", digest, size: 0, mode: 0o644 },
|
|
],
|
|
}),
|
|
).toThrow("used as a directory");
|
|
|
|
const parent = await temporaryDirectory("kuber-materialized-invalid-");
|
|
await expect(
|
|
materializeWorkspace(
|
|
join(parent, "tree"),
|
|
{
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
files: [{ path: "file", type: "file", digest, size: 1, mode: 0o644 }],
|
|
},
|
|
async () => Buffer.from("wrong"),
|
|
),
|
|
).rejects.toThrow("Blob verification failed");
|
|
});
|
|
});
|