diff --git a/nix/check-execution.mjs b/nix/check-execution.mjs index 585bfc7..c8ba1f0 100644 --- a/nix/check-execution.mjs +++ b/nix/check-execution.mjs @@ -13,25 +13,32 @@ for (const check of checks) { if (Object.hasOwn(roots, check.packageRevisionId)) throw Error("Duplicate checked package revision"); roots[check.packageRevisionId] = check.artifactPath; } -const checked = await compiler.loadCheckedArtifactWorld( - plan, - roots, - createHash("sha256") - .update(await readFile(compilerPath)) - .digest("hex"), -); -const installation = await compiler.loadExecutionInstallation( - plan, - roots, - createHash("sha256") - .update(await readFile(compilerPath)) - .digest("hex"), -); +const generator = createHash("sha256") + .update(await readFile(compilerPath)) + .digest("hex"); +// These compiler-dependent checks belong to the Nix build, not runtime recovery. +const candidate = JSON.parse(await readFile(join(output, "candidate.json"), "utf8")); +const { executionContracts: _legacyContracts, ...workspace } = candidate; +if (compiler.hexDigest(compiler.compileExecutionPlan(workspace)) !== compiler.hexDigest(plan)) + throw Error("CANDIDATE_PLAN_MISMATCH: build plan differs from source compilation"); +const checked = await compiler.loadCheckedArtifactWorld(plan, roots, generator); +const installation = await compiler.loadExecutionInstallation(plan, roots); +const kernel = compiler.compileKernelProgram(plan), + queries = compiler.compileQueryProgram(plan); for (const [file, value] of [ ["execution-world.json", checked.world], ["installation.json", installation], - ["kernel.json", compiler.compileKernelProgram(plan)], - ["queries.json", compiler.compileQueryProgram(plan)], - ["execution-artifacts.json", { codec: "camino-checked-execution-v1", roots, manifests: checked.manifests }], + ["kernel.json", kernel], + ["queries.json", queries], + [ + "execution-artifacts.json", + { + codec: "camino-checked-execution-v2", + generator, + roots, + manifests: checked.manifests, + programs: { kernel: compiler.hexDigest(kernel), queries: compiler.hexDigest(queries) }, + }, + ], ]) await writeFile(join(output, file), JSON.stringify(value) + "\n"); diff --git a/src/execution-world/candidate.ts b/src/execution-world/candidate.ts index 650c3f5..694ba48 100644 --- a/src/execution-world/candidate.ts +++ b/src/execution-world/candidate.ts @@ -1,11 +1,8 @@ import { open, lstat } from "node:fs/promises"; import { constants } from "node:fs"; import { join } from "node:path"; -import type { WorkspaceRevision } from "../capability-model/types.js"; -import { compileExecutionPlan, hexDigest, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; +import { hexDigest, ExecutionPlanError, type ExecutionPlan } from "./plan.js"; import { loadExecutionInstallation, type PackageInstallationSelection } from "./installation.js"; -import { compileKernelProgram } from "./kernel.js"; -import { compileQueryProgram } from "./queries.js"; const rootPattern = /^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/; async function readJson(root: string, file: string): Promise { const handle = await open(join(root, file), constants.O_RDONLY | constants.O_NOFOLLOW); @@ -17,29 +14,35 @@ async function readJson(root: string, file: string): Promise { await handle.close(); } } -/** Revalidate the normal checked-candidate output. This is the shared artifact - * loader, not a second managed-only executable manifest or an activation grant. */ +/** Load the checked Nix output, preserving its original compilation and provenance. + * The selected toolchain validates generated code during the build. This loader + * checks stored identities and artifact/runtime contracts, never recompiles the + * historical candidate with whichever compiler happens to run the host. */ export async function loadCheckedInstallationCandidate( root: string, - generator: string, selections: Readonly> = {}, ) { if (!rootPattern.test(root) || !(await lstat(root)).isDirectory()) throw Error("Candidate must be an immutable Nix output directory"); - const [candidate, recordedPlan, artifacts, source, entryRequest] = await Promise.all([ + const [candidate, plan, artifacts, source, entryRequest, kernel, queries] = await Promise.all([ readJson(root, "candidate.json"), readJson(root, "execution-plan.json"), readJson(root, "execution-artifacts.json"), readJson(root, "source.json"), readJson(root, "entry.json"), + readJson(root, "kernel.json"), + readJson(root, "queries.json"), ]); const { executionContracts: _legacyContracts, ...workspace } = candidate; - const plan = compileExecutionPlan(workspace as WorkspaceRevision); - if (hexDigest(plan) !== hexDigest(recordedPlan)) + if ( + plan?.definition?.codec !== "camino-execution-plan-v1" || + hexDigest(plan.definition) !== plan.digest || + hexDigest(workspace) !== hexDigest(plan.definition.workspace) + ) throw new ExecutionPlanError( "CANDIDATE_PLAN_MISMATCH", root, - "Recompiled source contract differs from the stored plan", + "Stored plan must match its digest and exact workspace source contract", ); if ( source.kind !== "workspace" || @@ -57,7 +60,9 @@ export async function loadCheckedInstallationCandidate( ) throw new ExecutionPlanError("CANDIDATE_SOURCE_INVALID", root, "Expected a source URL without credentials"); if ( - artifacts.codec !== "camino-checked-execution-v1" || + artifacts.codec !== "camino-checked-execution-v2" || + typeof artifacts.generator !== "string" || + !/^[0-9a-f]{64}$/.test(artifacts.generator) || !artifacts.roots || typeof artifacts.roots !== "object" || Array.isArray(artifacts.roots) @@ -70,30 +75,37 @@ export async function loadCheckedInstallationCandidate( root, "Package artifacts must be immutable Nix roots", ); - const installation = await loadExecutionInstallation(plan, artifacts.roots, generator, selections); + const installation = await loadExecutionInstallation(plan, artifacts.roots, selections); const entry = compileWorkspaceEntry(plan, entryRequest); - const kernel = compileKernelProgram(plan), - queries = compileQueryProgram(plan); - // The isolated runner and managed host consume the same compiled programs. - for (const [file, expected] of [ - ["kernel.json", kernel], - ["queries.json", queries], + for (const [name, program, codec] of [ + ["kernel", kernel, "camino-kernel-program-v1"], + ["queries", queries, "camino-query-program-v1"], ] as const) - if (hexDigest(await readJson(root, file)) !== hexDigest(expected)) + if (program?.codec !== codec || program.plan !== plan.digest || hexDigest(program) !== artifacts.programs?.[name]) throw new ExecutionPlanError( "CANDIDATE_PROGRAM_MISMATCH", - file, - "Stored interpreter input differs from checked compilation", + name, + "Stored interpreter input must match the checked build and plan", ); const provenance = { workspaceRevision: workspace.id as string, source: { repository: source.repository as string, commit: source.commit as string }, - generator, - packages: plan.definition.packages.map((pkg) => ({ revision: pkg.revision, source: pkg.source })), + generator: artifacts.generator as string, + packages: (plan as ExecutionPlan).definition.packages.map((pkg) => ({ + revision: pkg.revision, + source: pkg.source, + })), }; const reference = { root, - digest: hexDigest({ plan: plan.digest, world: installation.world, provenance, roots: artifacts.roots, entry }), + digest: hexDigest({ + plan: plan.digest, + world: installation.world, + provenance, + roots: artifacts.roots, + entry, + programs: artifacts.programs, + }), }; return { reference, @@ -105,7 +117,7 @@ export async function loadCheckedInstallationCandidate( kernel, queries, installation, - executionPlan: plan, + executionPlan: plan as ExecutionPlan, }; } diff --git a/src/execution-world/installation.ts b/src/execution-world/installation.ts index a18832d..116d330 100644 --- a/src/execution-world/installation.ts +++ b/src/execution-world/installation.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { loadCheckedArtifactWorld } from "./receipts.js"; -import { contentArtifactUrl } from "./files.js"; +import { contentArtifactUrl, loadArtifactWorld, type LoadedArtifactWorld } from "./files.js"; import { selectArtifactImplementation, type ArtifactImplementation, type ComponentArtifact } from "./artifacts.js"; import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js"; import { @@ -108,15 +108,32 @@ export interface InstalledComponent { readonly compatibility: ComponentArtifact["compatibility"]; readonly sharedModules: Readonly>; } -/** Verify candidate receipts before assembling process entrypoints and browser assets. - * This grants no authority to activate a world or run optional modules in process. */ -export async function loadExecutionInstallation( +/** Build-time validation uses the compiler selected by the Nix derivation. */ +export async function checkExecutionInstallation( plan: ExecutionPlan, roots: Readonly>, generator: string, selections: Readonly> = {}, ): Promise { const loaded = await loadCheckedArtifactWorld(plan, roots, generator); + return assembleExecutionInstallation(plan, roots, loaded, selections); +} +/** Load immutable artifacts without regenerating bindings with the host compiler. + * Format, coverage, content and runtime ABI checks still apply. This grants no + * authority to activate a world or run optional modules in process. */ +export async function loadExecutionInstallation( + plan: ExecutionPlan, + roots: Readonly>, + selections: Readonly> = {}, +): Promise { + return assembleExecutionInstallation(plan, roots, await loadArtifactWorld(plan, roots), selections); +} +function assembleExecutionInstallation( + plan: ExecutionPlan, + roots: Readonly>, + loaded: LoadedArtifactWorld, + selections: Readonly>, +): ExecutionInstallation { const fail = (where: string, message: string): never => { throw new ExecutionPlanError("INSTALLATION_INVALID", where, message); }; diff --git a/src/execution-world/receipts.ts b/src/execution-world/receipts.ts index fcd681a..b2de639 100644 --- a/src/execution-world/receipts.ts +++ b/src/execution-world/receipts.ts @@ -119,7 +119,8 @@ export async function checkArtifactReceipt( throw new ExecutionPlanError("INVALID_RECEIPT", file.path, "Generated bindings were replaced or omitted"); } } -/** Activation uses this entrypoint with its trusted generator identity, not the manifest-only loader. */ +/** Checked Nix builds validate generated sources with their selected compiler. + * Runtime loading uses the stored outputs instead of repeating compilation. */ export async function loadCheckedArtifactWorld( plan: ExecutionPlan, roots: Readonly>, diff --git a/test/presentations.test.ts b/test/presentations.test.ts index 9810bdd..af6b8fc 100644 --- a/test/presentations.test.ts +++ b/test/presentations.test.ts @@ -17,7 +17,7 @@ import { componentArtifactDigest, componentSharedModules, expectedArtifactReceipt, - loadExecutionInstallation, + checkExecutionInstallation, clientContractCatalog, type ComponentArtifact, type PackageArtifacts, @@ -208,7 +208,7 @@ test("checked component-only artifacts install without a service and reject miss await writeFile(path.join(root, file), content); } await writeFile(path.join(root, "share/quixos/build-receipt.json"), JSON.stringify(receipt)); - const installation = await loadExecutionInstallation(plan, { [pkg.revision]: root }, generator); + const installation = await checkExecutionInstallation(plan, { [pkg.revision]: root }, generator); assert.equal(installation.packages[0]!.service, undefined); assert.equal(installation.components[0]!.id, "component:card"); assert.ok(installation.routes[installation.components[0]!.url]);