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 { const path = await mkdtemp(join(tmpdir(), prefix)); temporaryDirectories.push(path); return path; } async function repository(): Promise { const root = await temporaryDirectory("kuber-workspace-"); await run("git", ["init", "-q", root]); await run("git", ["-C", root, "config", "user.email", "test@example.com"]); 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"); }); });