Files
quixos-protocol/src/execution-world/receipts.ts
T

169 lines
7.3 KiB
TypeScript
Raw Normal View History

import { createHash } from "node:crypto";
import { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } from "../bindings/portable.js";
import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js";
import { type ArtifactReader, type PackageArtifacts, type ArtifactGroup } from "./artifacts.js";
import { loadArtifactWorld, type LoadedArtifactWorld } from "./files.js";
import { componentAssetDeclarations } from "./client-contracts.js";
import { checkPackageBuildContract, packageBuildContract, type PackageBuildContract } from "./build-contract.js";
export interface GroupReceipt {
schemaVersion: 1;
packageRevisionId: string;
bindingDigest: string;
manifestDigest: string;
generatorDigest: string;
generated: { target: string; path: string; digest: string; bytes: number }[];
}
export interface ArtifactReceipt extends GroupReceipt {
sourceDigest: string;
}
const hashBytes = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
/** Nominal identities never become filesystem paths. */
export const generatedBindingPath = (target: string) => `share/quixos/generated/target-${hexDigest(target)}.ts`;
/** Deterministic expected receipt. A trusted build must also typecheck and bundle these exact sources.
* This is consistency evidence inside a trusted Nix build path, not remote build attestation. */
export function expectedArtifactReceipt(
plan: ExecutionPlan,
manifest: PackageArtifacts,
generatorDigest: string,
): { receipt: ArtifactReceipt; sources: Map<string, Uint8Array> } {
const pkg = plan.definition.packages.find((p) => p.revision === manifest.packageRevisionId);
if (!pkg || manifest.sourceDigest !== pkg.sourceDigest)
throw new ExecutionPlanError("INVALID_RECEIPT", manifest.packageRevisionId, "Receipt package/source mismatch");
return expectedPackageReceipt(
packageBuildContract(plan.definition.bindingSchema, pkg.revision),
manifest,
pkg.sourceDigest,
generatorDigest,
);
}
export function expectedPackageReceipt(
contract: PackageBuildContract,
manifest: PackageArtifacts,
sourceDigest: string,
generatorDigest: string,
): { receipt: ArtifactReceipt; sources: Map<string, Uint8Array> } {
if (manifest.sourceDigest !== sourceDigest)
throw new ExecutionPlanError("INVALID_RECEIPT", manifest.packageRevisionId, "Receipt source mismatch");
const result = expectedGroupReceipt(contract, manifest, generatorDigest);
return { ...result, receipt: { ...result.receipt, sourceDigest } };
}
export function expectedGroupReceipt(
contract: PackageBuildContract,
manifest: ArtifactGroup,
generatorDigest: string,
): { receipt: GroupReceipt; sources: Map<string, Uint8Array> } {
checkPackageBuildContract(contract);
if (!/^[0-9a-f]{64}$/.test(generatorDigest)) throw new Error("Invalid trusted generator digest");
const pkg = contract.definition.package;
const schemaPackage = contract.definition.bindings.packages.find((p) => p.revisionId === pkg.revision)!;
if (
manifest.packageRevisionId !== pkg.revision ||
manifest.bindingDigest !== contract.digest ||
hexDigest(manifest.versions) !== hexDigest(contract.definition.versions)
)
throw new ExecutionPlanError("INVALID_RECEIPT", manifest.packageRevisionId, "Receipt package/contract mismatch");
// A process service may implement transactions in any language. Only the
// checked JS module path promises generated TypeScript adapter evidence.
const available = new Set(
manifest.implementations
.filter(
(i) =>
i.compatibility === "quixos-js-module-v1" &&
i.exports.some((id) => pkg.exports.some((entry) => entry.id === id && entry.execution.kind !== "remote")),
)
.map((i) => i.target),
);
const targets = [
...new Set(
schemaPackage.exports.flatMap((e) => (e.execution && e.execution.kind !== "remote" ? e.execution.targets : [])),
),
]
.filter((t) => available.has(t))
.sort();
const sources = new Map<string, Uint8Array>();
const generated = targets.map((target) => {
const path = generatedBindingPath(target);
const bytes = new TextEncoder().encode(
generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, { target }),
);
sources.set(path, bytes);
return { target, path, digest: hashBytes(bytes), bytes: bytes.length };
});
if (manifest.components?.length) {
for (const [path, source] of [
["share/quixos/generated/client.ts", contract.definition.component!],
["share/quixos/generated/component-assets.d.ts", componentAssetDeclarations()],
] as const) {
const bytes = new TextEncoder().encode(source);
sources.set(path, bytes);
generated.push({ target: "window", path, digest: hashBytes(bytes), bytes: bytes.length });
}
}
// Custom language services need no TypeScript adapter. A checked JS module
// exporting remote handlers does, and must carry the exact generated source.
if (
manifest.implementations.some(
(implementation) =>
implementation.compatibility === "quixos-js-module-v1" &&
implementation.exports.some((id) =>
pkg.exports.some((entry) => entry.id === id && entry.execution.kind === "remote"),
),
)
) {
const path = "share/quixos/generated/remote.ts";
const bytes = new TextEncoder().encode(
generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision),
);
sources.set(path, bytes);
generated.push({ target: "server", path, digest: hashBytes(bytes), bytes: bytes.length });
}
return {
receipt: {
schemaVersion: 1,
packageRevisionId: pkg.revision,
bindingDigest: contract.digest,
manifestDigest: hexDigest(manifest),
generatorDigest,
generated,
},
sources,
};
}
export async function checkArtifactReceipt(
plan: ExecutionPlan,
manifest: PackageArtifacts,
receipt: unknown,
generatorDigest: string,
read: ArtifactReader,
): Promise<void> {
const expected = expectedArtifactReceipt(plan, manifest, generatorDigest);
if (hexDigest(receipt) !== hexDigest(expected.receipt))
throw new ExecutionPlanError(
"INVALID_RECEIPT",
manifest.packageRevisionId,
"Receipt differs from the trusted compiler/schema/artifact expectation",
);
for (const file of expected.receipt.generated) {
const actual = await read(manifest.packageRevisionId, file.path, file.bytes);
if (actual.length !== file.bytes || hashBytes(actual) !== file.digest)
throw new ExecutionPlanError("INVALID_RECEIPT", file.path, "Generated bindings were replaced or omitted");
}
}
/** 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<Record<string, string>>,
generatorDigest: string,
): Promise<LoadedArtifactWorld> {
const loaded = await loadArtifactWorld(plan, roots);
for (const manifest of loaded.manifests) {
const bytes = await loaded.read(manifest.packageRevisionId, "share/quixos/build-receipt.json", 16 * 1024 * 1024);
const receipt: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
await checkArtifactReceipt(plan, manifest, receipt, generatorDigest, loaded.read);
}
return loaded;
}