diff --git a/scripts/testbed-build-inputs.mjs b/scripts/testbed-build-inputs.mjs new file mode 100644 index 000000000..13f6678ba --- /dev/null +++ b/scripts/testbed-build-inputs.mjs @@ -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 }; +} diff --git a/scripts/testbed-build-inputs.test.mjs b/scripts/testbed-build-inputs.test.mjs new file mode 100644 index 000000000..19b1a612c --- /dev/null +++ b/scripts/testbed-build-inputs.test.mjs @@ -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 }); + } +});