import { execFile } from "node:child_process"; import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; /** * The deploy smoke test's release pair (ADR 0049): a package archive and its `.sha256` are one * logical release and reach the verifier together, from a directory every user can traverse. * * The regression this exists for: BuildKit's local exporter creates its output directory with mode * 0700, owned by whoever ran the build. On a workstation that is uid 1000, which is also the image's * unprivileged `node` user, so the verifier could read it; in CI the build runs as root, and the * verifier could read neither file ("The SHA-256 file could not be read"). */ const execFileAsync = promisify(execFile); const helper = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "lib", "module-package-release.sh", ); const archive = "example-0.1.0-any.tar.gz"; const digest = "91e5dfee80880027bd1c2e1796513f07023c03847e9ca3870b51f8272c3accf7"; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); /** A build output exactly as BuildKit leaves it: mode 0700, holding the archive and its digest. */ async function buildOutput( files: Readonly> = { [archive]: "archive bytes", [`${archive}.sha256`]: `${digest} ${archive}\n`, }, ): Promise<{ readonly root: string; readonly output: string }> { const root = await mkdtemp(path.join(tmpdir(), "ekklesia-release-pair-")); temporaryDirectories.push(root); const output = path.join(root, "module-package"); await mkdir(output); for (const [name, contents] of Object.entries(files)) { await writeFile(path.join(output, name), contents, { mode: 0o600 }); } await chmod(output, 0o700); return { output, root }; } async function release(output: string, target: string) { return execFileAsync("bash", [ "-c", 'source "$1"; module_package_release "$2" "$3"', "release", helper, output, target, ]); } describe("the smoke test's package release pair", () => { it("copies the archive and its digest, together, into a fresh world-traversable directory", async () => { const { output, root } = await buildOutput(); const target = path.join(root, "package"); const { stdout } = await release(output, target); expect(stdout.trim()).toBe(archive); expect((await readdir(target)).sort()).toEqual([archive, `${archive}.sha256`]); expect((await stat(target)).mode & 0o777).toBe(0o755); for (const name of [archive, `${archive}.sha256`]) { expect((await stat(path.join(target, name))).mode & 0o777, name).toBe(0o644); expect(await readFile(path.join(target, name))).toEqual( await readFile(path.join(output, name)), ); } }); it.each([ ["the digest is missing", { [archive]: "archive bytes" }], [ "the digest names another file", { [archive]: "a", [`${archive}.sha256`]: `${digest} other.tar.gz\n` }, ], ["the digest is malformed", { [archive]: "a", [`${archive}.sha256`]: "not-a-digest x\n" }], [ "the build left something else beside the pair", { [archive]: "a", [`${archive}.sha256`]: `${digest} ${archive}\n`, "stale.tar.gz.sha256": "x", }, ], [ "there is more than one archive", { [archive]: "a", [`${archive}.sha256`]: `${digest} ${archive}\n`, "example-0.0.9-any.tar.gz": "old", }, ], ["there is no archive at all", {}], ])("refuses when %s", async (_label, files) => { const { output, root } = await buildOutput(files); const target = path.join(root, "package"); await expect(release(output, target)).rejects.toThrow(); await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" }); }); it("never reuses a release directory that already exists", async () => { const { output, root } = await buildOutput(); const target = path.join(root, "package"); await mkdir(target); await writeFile(path.join(target, `${archive}.sha256`), `${"0".repeat(64)} ${archive}\n`); await expect(release(output, target)).rejects.toThrow(); // The stale digest is left exactly as it was; nothing was mixed into it. expect(await readdir(target)).toEqual([`${archive}.sha256`]); }); });