Keep cold Candidate verification cooperative with controller renewals
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user