feat(testbed): stub-assets census hash for the 51-04 manifest

The build verb derives the manifest's stubAssetsHash (plan §1) from the EXACT
stub-asset file set the Dockerfile COPYs — a byte-reproducible census receipt
scoped to the code path's file list, never a recursive over-count. Digest is over
canonical {path,sha256} content (delimiter-forgery-resistant); loud-fail on a
missing file / empty set / a path escaping the asset root. RED-first, 9/9 node:test.

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 16774a9452
commit 10e67c94aa
2 files changed
+257

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
// 51-04 testbed image — deriving the manifest's `stubAssetsHash` (plan §1) from the tree.
//
// The build verb (scripts/build-testbed-image.sh class) feeds the manifest four tree-derived
// inputs (git sha, node version, openrig sha, stub-assets hash) plus the base digest from the
// host-side `docker pull`. The stub-assets hash is the one requiring real derivation: it is the
// CENSUS RECEIPT of exactly which stub assets got baked into the image — a byte-reproducible
// identity so the runner / 51-05 matrix can compare runs across image versions.
//
// Scope discipline (census-scope-match-code-path): the caller passes the EXACT file list the
// Dockerfile COPYs — never a recursive walk, which would silently fold in untracked/generated
// siblings and make the receipt non-deterministic. The digest is over CANONICAL content: each
// file is hashed on its own, then a sorted {path,sha256} map is hashed — so a delimiter or quote
// embedded in a path can never forge a different honest asset set's digest
// (hash-join-delimiter-forgery). Missing file / empty set / a path escaping the asset root all
// fail LOUDLY — never a silent partial receipt that would look build-clean.
import { createHash } from "node:crypto";
import { 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
* receipt that omits (or over-counts) a baked-in file. */
export class TestbedBuildInputsError extends Error {
constructor(message) {
super(message);
this.name = "TestbedBuildInputsError";
}
}
/** The stub-assets receipt schema id — bumped only on a breaking receipt-shape change. */
export const STUB_ASSETS_SCHEMA = "openrig-testbed-stub-assets/v1";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
/** Resolve a caller-supplied relative asset path INSIDE rootDir, rejecting absolute paths and
* any `..` escape (containment). The input list is author-controlled, so the threat here is
* accidental over-reach / a receipt that reaches outside the asset tree — a prefix check on the
* resolved path covers it. */
function resolveContained(rootDir, rel) {
if (typeof rel !== "string" || rel.trim().length === 0) {
throw new TestbedBuildInputsError("stub asset path must be a non-empty string");
}
const rootResolved = resolve(rootDir);
const abs = resolve(rootResolved, rel);
if (abs !== rootResolved && !abs.startsWith(rootResolved + sep)) {
throw new TestbedBuildInputsError(`stub asset path '${rel}' escapes the asset root`);
}
return abs;
}
/** Normalize a relative path to a stable POSIX form for the receipt (so the identity is
* platform-independent: back-slashes never leak into the hashed payload). */
function posixPath(rel) {
return rel.split(sep).join("/");
}
/**
* Compute the stub-assets census hash over an explicit file list under rootDir.
* @returns {{ hash: string, receipt: { schema: string, files: Array<{path: string, sha256: string}> } }}
*/
export function deriveStubAssetsHash(rootDir, relativePaths) {
if (typeof rootDir !== "string" || rootDir.trim().length === 0) {
throw new TestbedBuildInputsError("stub assets root dir must be a non-empty string");
}
let rootStat;
try {
rootStat = statSync(resolve(rootDir));
} catch {
throw new TestbedBuildInputsError(`stub assets root '${rootDir}' does not exist`);
}
if (!rootStat.isDirectory()) {
throw new TestbedBuildInputsError(`stub assets root '${rootDir}' is not a directory`);
}
if (!Array.isArray(relativePaths) || relativePaths.length === 0) {
throw new TestbedBuildInputsError("stub asset file list must be a non-empty array");
}
const files = [];
const seen = new Set();
for (const rel of relativePaths) {
const abs = resolveContained(rootDir, rel);
const path = posixPath(rel);
if (seen.has(path)) {
throw new TestbedBuildInputsError(`stub asset path '${path}' listed more than once`);
}
seen.add(path);
let bytes;
try {
bytes = readFileSync(abs);
} catch {
throw new TestbedBuildInputsError(`stub asset '${rel}' does not exist under the asset root`);
}
if (!statSync(abs).isFile()) {
throw new TestbedBuildInputsError(`stub asset '${rel}' is not a regular file`);
}
files.push({ path, sha256: sha256(bytes) });
}
// Sort by POSIX path so the receipt + digest are order-independent. The digested payload is the
// schema + the sorted {path,sha256} entries — both fields JSON-escaped (path) or fixed-width hex
// (sha256), so no naive delimiter join can forge a distinct set.
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
const receipt = { schema: STUB_ASSETS_SCHEMA, files };
const hash = sha256(JSON.stringify(receipt));
return { hash, receipt };
}
+149
View File
@@ -0,0 +1,149 @@
import { test } from "node:test";
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";
// 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
// census receipt: a byte-reproducible identity of what shipped, scoped to the code path's
// exact file list (census-scope-match-code-path — an author-supplied list, NEVER a recursive
// walk that would over-count untracked/generated siblings). The digest is over CANONICAL
// content (each file hashed, then a sorted {path,sha256} map hashed) — never a naive path/
// content join a delimiter could forge (hash-join-delimiter-forgery). Loud-fail on a missing
// file / empty set / a path escaping the asset root (never a silent partial receipt).
/** Build a temp asset tree from a {relpath: content} map; returns its root dir. */
function makeAssetTree(entries) {
const root = mkdtempSync(join(tmpdir(), "testbed-assets-"));
for (const [rel, content] of Object.entries(entries)) {
const abs = join(root, rel);
mkdirSync(join(abs, ".."), { recursive: true });
writeFileSync(abs, content);
}
return root;
}
const ASSETS = Object.freeze({
"stub-runner.js": "// compiled stub runner\nexport const x = 1;\n",
"stub-runner.protocol.md": "the stub protocol\n",
"hooks/compaction-restore-bridge.cjs": "module.exports = {};\n",
});
test("produces a 64-hex digest + a sorted per-file census receipt over the asset set", () => {
const root = makeAssetTree(ASSETS);
try {
const { hash, receipt } = deriveStubAssetsHash(root, Object.keys(ASSETS));
assert.match(hash, /^[0-9a-f]{64}$/);
// Receipt lists every named asset, each with its own 64-hex content digest, SORTED by path.
assert.equal(receipt.files.length, 3);
assert.deepEqual(
receipt.files.map((f) => f.path),
["hooks/compaction-restore-bridge.cjs", "stub-runner.js", "stub-runner.protocol.md"],
);
for (const f of receipt.files) assert.match(f.sha256, /^[0-9a-f]{64}$/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("REBUILD CONTRACT: identical files => identical digest, INDEPENDENT of input list order", () => {
const a = makeAssetTree(ASSETS);
const b = makeAssetTree(ASSETS);
try {
const forward = deriveStubAssetsHash(a, Object.keys(ASSETS));
const reversed = deriveStubAssetsHash(b, [...Object.keys(ASSETS)].reverse());
assert.equal(forward.hash, reversed.hash);
assert.equal(JSON.stringify(forward.receipt), JSON.stringify(reversed.receipt));
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
});
test("content sensitivity: changing ANY file's bytes changes the digest", () => {
const base = makeAssetTree(ASSETS);
const mutated = makeAssetTree({ ...ASSETS, "stub-runner.js": "// DIFFERENT bytes\n" });
try {
assert.notEqual(
deriveStubAssetsHash(base, Object.keys(ASSETS)).hash,
deriveStubAssetsHash(mutated, Object.keys(ASSETS)).hash,
);
} finally {
rmSync(base, { recursive: true, force: true });
rmSync(mutated, { recursive: true, force: true });
}
});
test("path sensitivity: same content at a different path changes the digest (path is identity)", () => {
const a = makeAssetTree({ "a.js": "same", "b.js": "other" });
const b = makeAssetTree({ "renamed.js": "same", "b.js": "other" });
try {
assert.notEqual(
deriveStubAssetsHash(a, ["a.js", "b.js"]).hash,
deriveStubAssetsHash(b, ["renamed.js", "b.js"]).hash,
);
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
});
test("set sensitivity: adding or dropping a file changes the digest", () => {
const full = makeAssetTree(ASSETS);
try {
const all = deriveStubAssetsHash(full, Object.keys(ASSETS)).hash;
const fewer = deriveStubAssetsHash(full, ["stub-runner.js", "stub-runner.protocol.md"]).hash;
assert.notEqual(all, fewer);
} finally {
rmSync(full, { recursive: true, force: true });
}
});
test("forgery-resistance: a delimiter/quote in a path cannot forge another honest set's digest", () => {
// A naive `${path}:${contentHash}` join could let a crafted path embed a sibling's boundary
// and collide with a different, honest asset set. Canonical JSON escaping keeps them distinct.
const honest = makeAssetTree({ "a.js": "x", "b.js": "y" });
const forged = makeAssetTree({ 'a.js","sha256":"forged': "x", "b.js": "y" });
try {
assert.notEqual(
deriveStubAssetsHash(honest, ["a.js", "b.js"]).hash,
deriveStubAssetsHash(forged, ['a.js","sha256":"forged', "b.js"]).hash,
);
} finally {
rmSync(honest, { recursive: true, force: true });
rmSync(forged, { recursive: true, force: true });
}
});
test("loud-fail: empty asset set (never a silent empty receipt)", () => {
const root = makeAssetTree(ASSETS);
try {
assert.throws(() => deriveStubAssetsHash(root, []), TestbedBuildInputsError);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("loud-fail: a named asset that does not exist on disk", () => {
const root = makeAssetTree(ASSETS);
try {
assert.throws(
() => deriveStubAssetsHash(root, ["stub-runner.js", "does-not-exist.js"]),
TestbedBuildInputsError,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("loud-fail: a path escaping the asset root (containment guard)", () => {
const root = makeAssetTree(ASSETS);
try {
assert.throws(() => deriveStubAssetsHash(root, ["../escape.js"]), TestbedBuildInputsError);
assert.throws(() => deriveStubAssetsHash(root, ["/etc/passwd"]), TestbedBuildInputsError);
} finally {
rmSync(root, { recursive: true, force: true });
}
});