Keep cold Candidate verification cooperative with controller renewals

This commit is contained in:
Timothy J. Aveni
2026-10-12 18:24:38 -07:00
parent 755c008a88
commit a48fccc328
5 changed files with 93 additions and 11 deletions
+47
View File
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { setImmediate } from "node:timers/promises";
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js"; import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
import { validateWorkspaceRevision } from "./validation.js"; import { validateWorkspaceRevision } from "./validation.js";
@@ -21,6 +22,52 @@ export const canonicalJson = (value: unknown): string => {
}; };
export const contentDigest = (value: unknown) => export const contentDigest = (value: unknown) =>
`sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
/** Same canonical bytes as contentDigest, without monopolizing a live host's
* event loop while verifying large immutable metadata. No validation is skipped.
* This is for asynchronous loading, not a different build/wire identity. */
export async function contentDigestAsync(value: unknown): Promise<string> {
function* parts(value: unknown): Generator<string> {
if (Array.isArray(value)) {
yield "[";
for (let index = 0; index < value.length; index++) {
if (index) yield ",";
// Match canonicalJson's Array.map behavior for sparse arrays too.
if (index in value) yield* parts(value[index]);
}
yield "]";
} else if (value !== null && typeof value === "object") {
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
throw new Error("Expected a plain JSON object");
yield "{";
let first = true;
for (const key of Object.keys(value).sort(compareText)) {
const entry = (value as Record<string, unknown>)[key];
if (entry === undefined) continue;
if (!first) yield ",";
first = false;
yield JSON.stringify(key) + ":";
yield* parts(entry);
}
yield "}";
} else yield canonicalJson(value);
}
const hash = createHash("sha256");
let buffer = "",
sliceStarted = performance.now();
for (const part of parts(value)) {
buffer += part;
if (buffer.length >= 65536) {
hash.update(buffer);
buffer = "";
if (performance.now() - sliceStarted >= 8) {
await setImmediate();
sliceStarted = performance.now();
}
}
}
hash.update(buffer);
return `sha256:${hash.digest("hex")}`;
}
const semantic = (value: unknown): unknown => { const semantic = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(semantic); if (Array.isArray(value)) return value.map(semantic);
if (value && typeof value === "object") if (value && typeof value === "object")
+9 -2
View File
@@ -1,6 +1,13 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { canonicalJson } from "../capability-model/evolution.js"; import { canonicalJson } from "../capability-model/evolution.js";
import { type ExecutionPlan, type PlanPackage, ExecutionPlanError, hexDigest, executionVersions } from "./plan.js"; import {
type ExecutionPlan,
type PlanPackage,
ExecutionPlanError,
hexDigest,
hexDigestAsync,
executionVersions,
} from "./plan.js";
export interface ArtifactFile { export interface ArtifactFile {
path: string; path: string;
@@ -359,7 +366,7 @@ export async function checkExecutionWorld(
): Promise<CheckedWorld> { ): Promise<CheckedWorld> {
if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions)) if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions))
fail("world", "Unsupported plan codec versions"); fail("world", "Unsupported plan codec versions");
if (hexDigest(plan.definition) !== plan.digest) fail("plan", "Plan digest mismatch"); if ((await hexDigestAsync(plan.definition)) !== plan.digest) fail("plan", "Plan digest mismatch");
const manifests = inputs.map(parsePackageArtifacts); const manifests = inputs.map(parsePackageArtifacts);
unique( unique(
manifests.map((m) => m.packageRevisionId), manifests.map((m) => m.packageRevisionId),
+12 -8
View File
@@ -1,7 +1,7 @@
import { graphExecutionRepresentation } from "./graph-execution.js"; import { graphExecutionRepresentation } from "./graph-execution.js";
import { lstat } from "node:fs/promises"; import { lstat } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { hexDigest, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; import { hexDigest, hexDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
import { import {
loadExecutionInstallation, loadExecutionInstallation,
createClientContractCatalog, createClientContractCatalog,
@@ -50,7 +50,7 @@ export async function loadCheckedInstallationCandidate(
const input = await readJson(root, "candidate.json", scope); const input = await readJson(root, "candidate.json", scope);
try { try {
const { executionContracts: _legacyContracts, ...workspace } = input.value; const { executionContracts: _legacyContracts, ...workspace } = input.value;
return hexDigest(workspace); return await hexDigestAsync(workspace);
} finally { } finally {
input.releaseTree(); input.releaseTree();
} }
@@ -72,8 +72,8 @@ export async function loadCheckedInstallationCandidate(
const [plan, artifacts, source, entryRequest, kernel, queries] = values; const [plan, artifacts, source, entryRequest, kernel, queries] = values;
if ( if (
plan?.definition?.codec !== "camino-execution-plan-v1" || plan?.definition?.codec !== "camino-execution-plan-v1" ||
hexDigest(plan.definition) !== plan.digest || (await hexDigestAsync(plan.definition)) !== plan.digest ||
workspaceDigest !== hexDigest(plan.definition.workspace) workspaceDigest !== (await hexDigestAsync(plan.definition.workspace))
) )
throw new ExecutionPlanError( throw new ExecutionPlanError(
"CANDIDATE_PLAN_MISMATCH", "CANDIDATE_PLAN_MISMATCH",
@@ -117,7 +117,11 @@ export async function loadCheckedInstallationCandidate(
["kernel", kernel, "camino-kernel-program-v1"], ["kernel", kernel, "camino-kernel-program-v1"],
["queries", queries, "camino-query-program-v1"], ["queries", queries, "camino-query-program-v1"],
] as const) ] as const)
if (program?.codec !== codec || program.plan !== kernel.plan || hexDigest(program) !== artifacts.programs?.[name]) if (
program?.codec !== codec ||
program.plan !== kernel.plan ||
(await hexDigestAsync(program)) !== artifacts.programs?.[name]
)
throw new ExecutionPlanError( throw new ExecutionPlanError(
"CANDIDATE_PROGRAM_MISMATCH", "CANDIDATE_PROGRAM_MISMATCH",
name, name,
@@ -129,8 +133,8 @@ export async function loadCheckedInstallationCandidate(
await (async () => { await (async () => {
const input = await readJson(root, "graph-execution.json", scope); const input = await readJson(root, "graph-execution.json", scope);
try { try {
const digest = hexDigest(input.value); const digest = await hexDigestAsync(input.value);
if (digest !== hexDigest(expectedGraph) || artifacts.execution !== digest) if (digest !== (await hexDigestAsync(expectedGraph)) || artifacts.execution !== digest)
throw new ExecutionPlanError( throw new ExecutionPlanError(
"CANDIDATE_EXECUTION_MISMATCH", "CANDIDATE_EXECUTION_MISMATCH",
root, root,
@@ -164,7 +168,7 @@ export async function loadCheckedInstallationCandidate(
return { return {
reference, reference,
workspace: plan.definition.workspace.workspaceId, workspace: plan.definition.workspace.workspaceId,
world: hexDigest(expectedGraph), world: await hexDigestAsync(expectedGraph),
plan: kernel.plan as string, plan: kernel.plan as string,
graphExecution: expectedGraph, graphExecution: expectedGraph,
provenance, provenance,
+3 -1
View File
@@ -11,7 +11,7 @@ import type {
OperationId, OperationId,
} from "../capability-model/types.js"; } from "../capability-model/types.js";
import { validateWorkspaceRevision } from "../capability-model/validation.js"; import { validateWorkspaceRevision } from "../capability-model/validation.js";
import { canonicalJson, contentDigest } from "../capability-model/evolution.js"; import { canonicalJson, contentDigest, contentDigestAsync } from "../capability-model/evolution.js";
import { resolveForwardedBinding } from "../capability-model/forwarding.js"; import { resolveForwardedBinding } from "../capability-model/forwarding.js";
import { linkQueries, type LinkedQuery } from "../query/link.js"; import { linkQueries, type LinkedQuery } from "../query/link.js";
import { type BindingSchema } from "../bindings/index.js"; import { type BindingSchema } from "../bindings/index.js";
@@ -29,6 +29,8 @@ export class ExecutionPlanError extends Error {
} }
} }
export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length); export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length);
export const hexDigestAsync = async (value: unknown): Promise<string> =>
(await contentDigestAsync(value)).slice("sha256:".length);
const sorted = <T>(values: readonly T[], key: (value: T) => string): T[] => const sorted = <T>(values: readonly T[], key: (value: T) => string): T[] =>
[...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)); [...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
export const publicOperationKey = (atom: string, iface: string, operation: string): string => export const publicOperationKey = (atom: string, iface: string, operation: string): string =>
+22
View File
@@ -3,6 +3,7 @@ import { test } from "node:test";
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js"; import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
import { import {
contentDigest, contentDigest,
contentDigestAsync,
planEvolution, planEvolution,
reviewSemanticMajors, reviewSemanticMajors,
runtimeContracts, runtimeContracts,
@@ -51,6 +52,27 @@ test("hashing is canonical and rejects values outside the persisted JSON contrac
assert.throws(() => contentDigest(new Date())); assert.throws(() => contentDigest(new Date()));
}); });
test("cooperative hashing preserves canonical identities and services the event loop", async () => {
const sparse = [1, 2, 3];
delete sparse[1];
const values = [null, false, -0, "中\\n\ud800", sparse, { z: 1, a: { omitted: undefined, b: [false, 1e100] } }];
for (const value of values) assert.equal(await contentDigestAsync(value), contentDigest(value));
for (const value of [undefined, NaN, Infinity, new Date(), [undefined]])
await assert.rejects(contentDigestAsync(value));
const large = Array.from({ length: 50000 }, (_, index) => ({ z: "definition".repeat(8), a: index }));
const expected = contentDigest(large);
let serviced = false;
const timer = setTimeout(() => {
serviced = true;
}, 0);
try {
assert.equal(await contentDigestAsync(large), expected);
assert.equal(serviced, true, "large verification must let host renewals and RPCs run");
} finally {
clearTimeout(timer);
}
});
test("a workspace root change and display names do not restart package runtimes", () => { test("a workspace root change and display names do not restart package runtimes", () => {
const before = makeValidCapabilityWorkspace(); const before = makeValidCapabilityWorkspace();
const after = structuredClone(before); const after = structuredClone(before);