diff --git a/scripts/testbed-build-inputs.mjs b/scripts/testbed-build-inputs.mjs index 13f6678ba..4a3c12424 100644 --- a/scripts/testbed-build-inputs.mjs +++ b/scripts/testbed-build-inputs.mjs @@ -15,7 +15,7 @@ // fail LOUDLY — never a silent partial receipt that would look build-clean. import { createHash } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve, sep } from "node:path"; /** Loud, typed failure — a missing/invalid asset input must fail, never a silent partial @@ -106,3 +106,66 @@ export function deriveStubAssetsHash(rootDir, relativePaths) { const hash = sha256(JSON.stringify(receipt)); return { hash, receipt }; } + +/** + * Parse + ENFORCE a digest-pinned base image reference (plan §1 fence: "digest-pinned, not + * tag-floating"). A tag-floating base drifts silently between builds and breaks byte-repro; only a + * `name[:tag]@sha256:<64-hex>` reference is accepted. The returned digest becomes the manifest's + * baseDigest. Anything else — a tag-only ref, a malformed/non-sha256 digest, empty — fails LOUDLY. + * @returns {{ ref: string, name: string, digest: string }} + */ +export function parseDigestPinnedBase(ref) { + if (typeof ref !== "string" || ref.trim().length === 0) { + throw new TestbedBuildInputsError("base image reference must be a non-empty string"); + } + const trimmed = ref.trim(); + const at = trimmed.indexOf("@"); + if (at === -1) { + throw new TestbedBuildInputsError( + `base image '${trimmed}' is tag-floating — it must be digest-pinned (name@sha256:<64-hex>)`, + ); + } + const name = trimmed.slice(0, at); + const digest = trimmed.slice(at + 1); + if (name.trim().length === 0) { + throw new TestbedBuildInputsError(`base image '${trimmed}' is missing a name before the digest`); + } + // Only sha256, exactly 64 lowercase hex — matches how docker records a pinned digest. + if (!/^sha256:[0-9a-f]{64}$/.test(digest)) { + throw new TestbedBuildInputsError( + `base image digest '${digest}' is not a valid sha256:<64 lowercase hex> pin`, + ); + } + return { ref: trimmed, name, digest }; +} + +/** + * Read the committed base-image slot (docker/testbed/base-image) — the digest resolved HOST-side by + * the L0 runbook and recorded for byte-reproducibility. Comment (`#`) and blank lines are ignored; + * exactly ONE reference must remain. An unresolved slot (comment-only), an ambiguous slot (multiple + * refs), or a missing file all fail LOUDLY — the tree can never build against an unpinned base. + * @returns {{ ref: string, name: string, digest: string }} + */ +export function readBaseImage(filePath) { + if (typeof filePath !== "string" || filePath.trim().length === 0) { + throw new TestbedBuildInputsError("base-image file path must be a non-empty string"); + } + if (!existsSync(filePath)) { + throw new TestbedBuildInputsError(`base-image file '${filePath}' does not exist`); + } + const refs = readFileSync(filePath, "utf8") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); + if (refs.length === 0) { + throw new TestbedBuildInputsError( + `base-image slot '${filePath}' is unresolved — resolve the digest host-side (L0 runbook)`, + ); + } + if (refs.length > 1) { + throw new TestbedBuildInputsError( + `base-image slot '${filePath}' names ${refs.length} refs — exactly one digest-pinned base is required`, + ); + } + return parseDigestPinnedBase(refs[0]); +} diff --git a/scripts/testbed-build-inputs.test.mjs b/scripts/testbed-build-inputs.test.mjs index 19b1a612c..6bec2cde7 100644 --- a/scripts/testbed-build-inputs.test.mjs +++ b/scripts/testbed-build-inputs.test.mjs @@ -3,7 +3,12 @@ import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { deriveStubAssetsHash, TestbedBuildInputsError } from "./testbed-build-inputs.mjs"; +import { + deriveStubAssetsHash, + parseDigestPinnedBase, + readBaseImage, + TestbedBuildInputsError, +} from "./testbed-build-inputs.mjs"; // 51-04 testbed image — the build verb derives the manifest's `stubAssetsHash` (plan §1) by // hashing the EXACT stub-asset file set the Dockerfile COPYs into the image. This is the @@ -147,3 +152,74 @@ test("loud-fail: a path escaping the asset root (containment guard)", () => { rmSync(root, { recursive: true, force: true }); } }); + +// --- base image: the digest-pin fence (plan §1: "digest-pinned, not tag-floating") --- +// The base LTS-slim Linux MUST be pinned by @sha256 digest so the image is byte-reproducible; a +// tag-floating base (`debian:bookworm-slim`) drifts silently between builds. The resolved digest +// becomes the manifest's baseDigest. The build verb refuses anything but a digest-pinned ref; the +// digest is resolved HOST-side (the locus ruling) and recorded in docker/testbed/base-image. + +const GOOD_DIGEST = "sha256:" + "a".repeat(64); + +test("parseDigestPinnedBase accepts a digest-pinned ref and returns its name + sha256 digest", () => { + const { ref, name, digest } = parseDigestPinnedBase(`debian:bookworm-slim@${GOOD_DIGEST}`); + assert.equal(ref, `debian:bookworm-slim@${GOOD_DIGEST}`); + assert.equal(name, "debian:bookworm-slim"); + assert.equal(digest, GOOD_DIGEST); + // A fully-qualified registry path is fine too. + assert.equal(parseDigestPinnedBase(`docker.io/library/debian@${GOOD_DIGEST}`).digest, GOOD_DIGEST); +}); + +test("parseDigestPinnedBase REFUSES a tag-floating (non-@sha256) base — the fence", () => { + assert.throws(() => parseDigestPinnedBase("debian:bookworm-slim"), TestbedBuildInputsError); + assert.throws(() => parseDigestPinnedBase("node:22.22.1-bookworm-slim"), TestbedBuildInputsError); +}); + +test("parseDigestPinnedBase REFUSES a malformed digest (short hex / non-hex / wrong algo)", () => { + assert.throws(() => parseDigestPinnedBase("debian@sha256:" + "a".repeat(63)), TestbedBuildInputsError); + assert.throws(() => parseDigestPinnedBase("debian@sha256:" + "g".repeat(64)), TestbedBuildInputsError); + assert.throws(() => parseDigestPinnedBase("debian@sha256:" + "A".repeat(64)), TestbedBuildInputsError); + assert.throws(() => parseDigestPinnedBase("debian@sha512:" + "a".repeat(128)), TestbedBuildInputsError); +}); + +test("parseDigestPinnedBase REFUSES empty / non-string input", () => { + assert.throws(() => parseDigestPinnedBase(""), TestbedBuildInputsError); + assert.throws(() => parseDigestPinnedBase(" "), TestbedBuildInputsError); + assert.throws(() => parseDigestPinnedBase(null), TestbedBuildInputsError); +}); + +test("readBaseImage reads a comment-tolerant single digest-pinned ref from a file", () => { + const dir = mkdtempSync(join(tmpdir(), "testbed-base-")); + try { + const p = join(dir, "base-image"); + writeFileSync(p, `# resolved host-side per the L0 runbook\n\ndebian:bookworm-slim@${GOOD_DIGEST}\n`); + const { digest, name } = readBaseImage(p); + assert.equal(digest, GOOD_DIGEST); + assert.equal(name, "debian:bookworm-slim"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("readBaseImage loud-fails on an UNRESOLVED slot (comment-only — no ref yet)", () => { + const dir = mkdtempSync(join(tmpdir(), "testbed-base-")); + try { + const p = join(dir, "base-image"); + writeFileSync(p, "# not yet resolved — run the host-side base-image resolve step\n"); + assert.throws(() => readBaseImage(p), TestbedBuildInputsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("readBaseImage loud-fails on multiple refs (ambiguous) and on a missing file", () => { + const dir = mkdtempSync(join(tmpdir(), "testbed-base-")); + try { + const p = join(dir, "base-image"); + writeFileSync(p, `debian@${GOOD_DIGEST}\nubuntu@${GOOD_DIGEST}\n`); + assert.throws(() => readBaseImage(p), TestbedBuildInputsError); + assert.throws(() => readBaseImage(join(dir, "nope")), TestbedBuildInputsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});