mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
026a7eac50
commit
bf52288bd0
2 files changed
+141
-2
No files matched your search
@@ -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]);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user