diff --git a/test/packaging/build-bin.test.ts b/test/packaging/build-bin.test.ts new file mode 100644 index 0000000..d1f96ad --- /dev/null +++ b/test/packaging/build-bin.test.ts @@ -0,0 +1,180 @@ +// `script/build-bin` compiles `bin/quak`, the single binary that the README's +// cron setup runs. bun compiles it from a copy of its own executable, so the +// binary loads through the same C library as the bun that built it. nix's bun +// loads through a C library in the nix store, which garbage collection can +// delete. On Linux the script therefore downloads bun's own release build, +// which loads through the system's C library. +// +// What the script promises, and why each test matters: +// +// - On Linux, each machine and C library that bun publishes a build for gets +// its own archive: x86-64 or arm64, with glibc or with musl, as on Alpine. A +// bun built for another machine or C library does not start, and +// `make install` fails. +// - The archive's SHA-256 must match the one in the script before anything in +// the archive runs. Without that check, a tampered download would build the +// binary that cron runs. +// - Linux on any other machine stops with an error. Building there with nix's +// bun would bring back the dependency on the nix store. +// - Other systems, such as macOS, compile with nix's bun as before, and +// download nothing. +// +// Each test copies the script into a fresh directory, which the script then +// treats as the checkout, and runs it there. Stubs first on PATH stand in for +// `uname`, `ldd`, `curl`, `unzip` and `nix-shell`, so no test downloads +// anything or runs a real bun. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +// The stubs, keyed by the command each replaces. `uname` and `ldd` describe the +// system that runBuildBin passes in the environment. `curl`, `unzip` and +// `nix-shell` write the arguments they were given, one per line, to a file +// named after the command in $STUB_LOG_DIR, which also shows that they ran. +const stubs: Record = { + uname: ` + case "$1" in + -s) echo "$STUB_UNAME_S" ;; + -m) echo "$STUB_UNAME_M" ;; + esac`, + // The script looks only at the start of the line: "musl libc" or not. + ldd: ` + if [ "$STUB_LIBC" = musl ]; then + echo "musl libc (x86_64)" + else + echo "ldd (GNU libc) 2.36" + fi`, + // Writes a file that is not the archive to the path after -o. + curl: ` + printf '%s\\n' "$@" > "$STUB_LOG_DIR/curl" + while [ $# -gt 1 ]; do + if [ "$1" = -o ]; then echo "not the archive" > "$2"; fi + shift + done`, + // Unpacks a stub bun where the real archive puts bun: in a directory named + // after the archive, inside the directory after -d. That bun writes + // bin/quak, as a real compile would. + unzip: ` + printf '%s\\n' "$@" > "$STUB_LOG_DIR/unzip" + for arg in "$@"; do + case "$arg" in *.zip) archive="$arg" ;; esac + if [ "$previous" = -d ]; then directory="$arg"; fi + previous="$arg" + done + bun="$directory/$(basename "$archive" .zip)/bun" + mkdir -p "$(dirname "$bun")" + echo '#!/bin/sh' > "$bun" + echo 'mkdir -p bin && echo binary > bin/quak' >> "$bun" + chmod +x "$bun"`, + "nix-shell": ` + printf '%s\\n' "$@" > "$STUB_LOG_DIR/nix-shell"`, +}; + +let checkout = ""; + +const writeScript = (path: string, body: string): void => { + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); +}; + +// A checkout holding the script, a stub script/build that does nothing, the +// stubs, and the directory they log to. +beforeEach(() => { + checkout = mkdtempSync(join(tmpdir(), "quak-build-bin-")); + mkdirSync(join(checkout, "script")); + copyFileSync( + join(repoRoot, "script/build-bin"), + join(checkout, "script/build-bin"), + ); + chmodSync(join(checkout, "script/build-bin"), 0o755); + writeScript(join(checkout, "script/build"), "exit 0"); + mkdirSync(join(checkout, "stubs")); + for (const [command, body] of Object.entries(stubs)) { + writeScript(join(checkout, "stubs", command), body); + } + mkdirSync(join(checkout, "log")); +}); + +afterEach(() => { + rmSync(checkout, { recursive: true, force: true }); +}); + +// Runs the script with nothing in its environment but the stubs' settings and a +// PATH that finds the stubs first. +const runBuildBin = (system: string, machine: string, libc = "glibc") => + spawnSync(join(checkout, "script/build-bin"), { + cwd: checkout, + encoding: "utf-8", + env: { + PATH: `${join(checkout, "stubs")}:${process.env.PATH}`, + STUB_UNAME_S: system, + STUB_UNAME_M: machine, + STUB_LIBC: libc, + STUB_LOG_DIR: join(checkout, "log"), + }, + }); + +const ran = (command: string): boolean => + existsSync(join(checkout, "log", command)); + +const argumentsOf = (command: string): string[] => + readFileSync(join(checkout, "log", command), "utf-8") + .trimEnd() + .split("\n"); + +describe("script/build-bin", () => { + // The stub curl writes a file that is not the archive, so the hash check + // must stop the script before unzip runs. A script without the check would + // run unzip, and the stub bun it unpacks would write bin/quak. + it.each([ + ["x86_64", "glibc", "bun-linux-x64-baseline.zip"], + ["x86_64", "musl", "bun-linux-x64-musl-baseline.zip"], + ["aarch64", "glibc", "bun-linux-aarch64.zip"], + ["aarch64", "musl", "bun-linux-aarch64-musl.zip"], + ])( + "on Linux %s with %s, downloads %s and stops when its hash does not match", + (machine, libc, archive) => { + const result = runBuildBin("Linux", machine, libc); + expect(argumentsOf("curl")).toContain( + "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" + + archive, + ); + expect(result.status).not.toBe(0); + expect(ran("unzip")).toBe(false); + expect(existsSync(join(checkout, "bin/quak"))).toBe(false); + }, + ); + + it("stops on a Linux machine that bun publishes no build for", () => { + const result = runBuildBin("Linux", "riscv64"); + expect(result.status).not.toBe(0); + expect(ran("curl")).toBe(false); + expect(ran("nix-shell")).toBe(false); + }); + + it("compiles with nix's bun on macOS", () => { + const result = runBuildBin("Darwin", "arm64"); + expect(result.status).toBe(0); + expect(ran("curl")).toBe(false); + expect(argumentsOf("nix-shell")).toEqual([ + "-p", + "bun", + "--run", + "bun build dist/bin/quak.js --compile --outfile bin/quak", + ]); + }); +});