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