feat(testbed): digest-pin fence for the base image (plan §1)

parseDigestPinnedBase enforces the byte-repro fence — only name@sha256:<64-hex> is
accepted; a tag-floating base, a malformed/non-sha256 digest, or empty fails loudly.
readBaseImage reads the committed docker/testbed/base-image slot (host-resolved per the
locus ruling), ignoring comments, requiring exactly one ref — an unresolved/ambiguous/
missing slot refuses, so the tree can never build against an unpinned base. The resolved
digest becomes the manifest baseDigest. RED-first, +7 node:test (16/16 file total).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-06 05:23:49 +00:00
1 parent 026a7eac50
commit bf52288bd0
2 files changed
+141 -2

No files matched your search

+64 -1
View File
@@ -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]);
}
+77 -1
View File
@@ -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 });
}
});