496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readlink,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
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("auto uses Git ignore rules even for tracked files, not .dockerignore", async () => {
|
|
const root = await repository();
|
|
await mkdir(join(root, "nested"));
|
|
await writeFile(join(root, ".gitignore"), "*.secret\n.env*\nignored/\n");
|
|
await writeFile(join(root, "nested/.gitignore"), "*.log\n!important.log\n");
|
|
await writeFile(join(root, ".dockerignore"), "Dockerfile\nvisible.txt\n");
|
|
await writeFile(join(root, "Dockerfile"), "FROM scratch\n");
|
|
await writeFile(join(root, "visible.txt"), "visible");
|
|
await writeFile(join(root, "tracked.secret"), "tracked secret");
|
|
await writeFile(join(root, ".env.tracked"), "tracked dotenv");
|
|
await run("git", [
|
|
"-C",
|
|
root,
|
|
"add",
|
|
"-f",
|
|
"tracked.secret",
|
|
".env.tracked",
|
|
]);
|
|
await writeFile(join(root, "untracked.secret"), "untracked secret");
|
|
await writeFile(join(root, ".env.local"), "untracked dotenv");
|
|
await writeFile(join(root, "nested/debug.log"), "ignored");
|
|
await run("git", ["-C", root, "add", "-f", "nested/debug.log"]);
|
|
await writeFile(join(root, "nested/important.log"), "included");
|
|
|
|
const regular = await enumerateWorkspace(root);
|
|
const auto = await enumerateWorkspace(root, "auto");
|
|
expect(regular.manifest.files.map((file) => file.path)).toContain(
|
|
"tracked.secret",
|
|
);
|
|
expect(regular.manifest.files.map((file) => file.path)).toContain(
|
|
".env.local",
|
|
);
|
|
expect(auto.manifest.files.map((file) => file.path)).toEqual([
|
|
".dockerignore",
|
|
".gitignore",
|
|
"Dockerfile",
|
|
"nested/.gitignore",
|
|
"nested/important.log",
|
|
"visible.txt",
|
|
]);
|
|
expect(auto.digest).not.toBe(regular.digest);
|
|
expect(await enumerateWorkspace(root, "auto")).toEqual(auto);
|
|
});
|
|
|
|
test("auto rejects selected dotenv and conventional credential paths", async () => {
|
|
for (const [path, tracked] of [
|
|
[".env", true],
|
|
[".env.local", false],
|
|
[".npmrc", false],
|
|
["nested/.ssh/id_ed25519", false],
|
|
["services/secrets/production.yaml", false],
|
|
] as const) {
|
|
const root = await repository();
|
|
const file = join(root, path);
|
|
await mkdir(join(file, ".."), { recursive: true });
|
|
await writeFile(file, "credential");
|
|
if (tracked) await run("git", ["-C", root, "add", "-f", path]);
|
|
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
|
|
`refuses to snapshot potential credentials at ${path}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("auto does not reject ignored dotenv or safe sample files", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, ".gitignore"), ".env\nignored/.env.local\n");
|
|
await writeFile(join(root, ".env"), "ignored secret");
|
|
await mkdir(join(root, "ignored"));
|
|
await writeFile(join(root, "ignored/.env.local"), "ignored secret");
|
|
await writeFile(join(root, ".env.example"), "TOKEN=replace-me");
|
|
await writeFile(join(root, "secretary-notes.txt"), "ordinary source");
|
|
|
|
const snapshot = await enumerateWorkspace(root, "auto");
|
|
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
|
|
".env.example",
|
|
".gitignore",
|
|
"secretary-notes.txt",
|
|
]);
|
|
});
|
|
|
|
test("auto fails closed without a Git repository or executable", async () => {
|
|
const root = await temporaryDirectory("kuber-auto-no-git-");
|
|
await writeFile(join(root, "app.txt"), "app");
|
|
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
|
|
/requires Git and a Git repository/,
|
|
);
|
|
await run("git", ["init", "-q", root]);
|
|
const previousPath = process.env.PATH;
|
|
try {
|
|
process.env.PATH = "";
|
|
await expect(enumerateWorkspace(root, "auto")).rejects.toThrow(
|
|
/requires Git and a Git repository/,
|
|
);
|
|
} finally {
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
}
|
|
});
|
|
|
|
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",
|
|
);
|
|
|
|
const autoRoot = await repository();
|
|
await run("ln", ["-s", "../outside", join(autoRoot, "escape")]);
|
|
await expect(enumerateWorkspace(autoRoot, "auto")).rejects.toThrow(
|
|
"Symlink escapes workspace",
|
|
);
|
|
});
|
|
|
|
test("does not read Git-tracked files through replaced parent symlinks in either mode", async () => {
|
|
const root = await repository();
|
|
const outside = await temporaryDirectory("kuber-workspace-outside-");
|
|
await mkdir(join(root, "sub"));
|
|
await writeFile(join(root, "sub/visible.txt"), "original");
|
|
await run("git", ["-C", root, "add", "sub/visible.txt"]);
|
|
await rm(join(root, "sub"), { recursive: true });
|
|
await writeFile(join(outside, "visible.txt"), "outside content");
|
|
await symlink(outside, join(root, "sub"));
|
|
|
|
// Git itself may reject a tracked path beneath a symlink before the
|
|
// parent guard runs; both outcomes must fail closed.
|
|
for (const mode of ["default", "auto"] as const)
|
|
await expect(enumerateWorkspace(root, mode)).rejects.toThrow();
|
|
});
|
|
|
|
test("rejects snapshot symlink chains before normalizing target components", async () => {
|
|
const root = await repository();
|
|
await symlink(".", join(root, "sub"));
|
|
await symlink("sub/..", join(root, "chain"));
|
|
|
|
for (const mode of ["default", "auto"] as const) {
|
|
await expect(enumerateWorkspace(root, mode)).rejects.toThrow(
|
|
"Symlink traverses snapshot symlink: chain -> sub/..",
|
|
);
|
|
}
|
|
|
|
const blobs = new Map(
|
|
["sub/..", "."].map((target) => {
|
|
const data = Buffer.from(target);
|
|
const digest =
|
|
`sha256:${createHash("sha256").update(data).digest("hex")}` as const;
|
|
return [digest, data] as const;
|
|
}),
|
|
);
|
|
const manifest: WorkspaceManifest = {
|
|
version: BUILD_PROTOCOL_VERSION,
|
|
files: [
|
|
{
|
|
path: "chain",
|
|
type: "symlink",
|
|
digest: [...blobs.keys()][0]!,
|
|
size: Buffer.byteLength("sub/.."),
|
|
mode: 0o777,
|
|
},
|
|
{
|
|
path: "sub",
|
|
type: "symlink",
|
|
digest: [...blobs.keys()][1]!,
|
|
size: Buffer.byteLength("."),
|
|
mode: 0o777,
|
|
},
|
|
],
|
|
};
|
|
const destination = join(await temporaryDirectory("kuber-chain-"), "tree");
|
|
await expect(
|
|
materializeWorkspace(destination, manifest, async (digest) =>
|
|
blobs.get(digest)!,
|
|
),
|
|
).rejects.toThrow("Symlink traverses snapshot symlink: chain -> sub/..");
|
|
await expect(stat(destination)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
test("retains safe symlinks in both snapshot modes and materialization", async () => {
|
|
const root = await repository();
|
|
await writeFile(join(root, "app.txt"), "safe");
|
|
await symlink(".", join(root, "sub"));
|
|
await symlink("./app.txt", join(root, "alias"));
|
|
|
|
for (const mode of ["default", "auto"] as const) {
|
|
const snapshot = await enumerateWorkspace(root, mode);
|
|
expect(snapshot.manifest.files.map((file) => file.path)).toEqual([
|
|
"alias",
|
|
"app.txt",
|
|
"sub",
|
|
]);
|
|
const blobs = new Map(
|
|
snapshot.blobs.map((blob) => [blob.digest, blob.data]),
|
|
);
|
|
const destination = join(
|
|
await temporaryDirectory("kuber-safe-links-"),
|
|
"tree",
|
|
);
|
|
await materializeWorkspace(
|
|
destination,
|
|
snapshot.manifest,
|
|
async (digest) => blobs.get(digest)!,
|
|
);
|
|
expect(await readlink(join(destination, "sub"))).toBe(".");
|
|
expect(await readFile(join(destination, "alias"), "utf8")).toBe("safe");
|
|
}
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|