282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|||
|
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||
|
|
import { type ExecutionPlan, type PlanPackage, ExecutionPlanError, hexDigest, executionVersions } from "./plan.js";
|
||
|
|
|
||
|
|
export interface ArtifactFile {
|
||
|
|
path: string;
|
||
|
|
digest: string;
|
||
|
|
bytes: number;
|
||
|
|
mediaType: string;
|
||
|
|
}
|
||
|
|
export interface ArtifactImplementation {
|
||
|
|
id: string;
|
||
|
|
target: string;
|
||
|
|
compatibility: "quixos-service-v1" | "quixos-js-module-v1";
|
||
|
|
path: string;
|
||
|
|
ports: "quixos-transaction-ports-v1";
|
||
|
|
registryExport?: string;
|
||
|
|
exports: string[];
|
||
|
|
/** Every executable/imported local asset, including the entrypoint, must be declared. */
|
||
|
|
files: ArtifactFile[];
|
||
|
|
contentDigest: string;
|
||
|
|
}
|
||
|
|
export interface PackageArtifacts {
|
||
|
|
schemaVersion: 1;
|
||
|
|
packageRevisionId: string;
|
||
|
|
bindingDigest: string;
|
||
|
|
sourceDigest: string;
|
||
|
|
versions: ExecutionPlan["definition"]["versions"];
|
||
|
|
implementations: ArtifactImplementation[];
|
||
|
|
}
|
||
|
|
export interface CheckedWorld {
|
||
|
|
codec: "camino-world-v1";
|
||
|
|
plan: string;
|
||
|
|
artifacts: { binding: string; target: string; digest: string }[];
|
||
|
|
versions: Record<string, string>;
|
||
|
|
}
|
||
|
|
const fail = (path: string, message: string): never => {
|
||
|
|
throw new ExecutionPlanError("INVALID_ARTIFACT", path, message);
|
||
|
|
};
|
||
|
|
const object = (value: unknown, path: string): Record<string, unknown> => {
|
||
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
|
||
|
|
fail(path, "Expected a plain JSON record");
|
||
|
|
return value as Record<string, unknown>;
|
||
|
|
};
|
||
|
|
const keys = (record: Record<string, unknown>, required: string[], optional: string[], path: string) => {
|
||
|
|
if (
|
||
|
|
required.some((key) => !Object.hasOwn(record, key)) ||
|
||
|
|
Object.keys(record).some((key) => ![...required, ...optional].includes(key))
|
||
|
|
)
|
||
|
|
fail(path, "Unexpected or missing fields");
|
||
|
|
};
|
||
|
|
const text = (value: unknown, path: string): string => {
|
||
|
|
if (
|
||
|
|
typeof value !== "string" ||
|
||
|
|
!value.length ||
|
||
|
|
value.length > 4096 ||
|
||
|
|
value.includes("\0") ||
|
||
|
|
Buffer.from(value, "utf8").toString("utf8") !== value
|
||
|
|
)
|
||
|
|
fail(path, "Expected bounded nonempty text");
|
||
|
|
return value as string;
|
||
|
|
};
|
||
|
|
const digest = (value: unknown, path: string): string => {
|
||
|
|
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) fail(path, "Expected SHA-256 hex");
|
||
|
|
return value as string;
|
||
|
|
};
|
||
|
|
const array = (value: unknown, path: string): unknown[] => {
|
||
|
|
if (!Array.isArray(value) || value.length > 10000) fail(path, "Expected a bounded array");
|
||
|
|
return value as unknown[];
|
||
|
|
};
|
||
|
|
const unique = (values: string[], path: string) => {
|
||
|
|
if (new Set(values).size !== values.length) fail(path, "Duplicate identities");
|
||
|
|
};
|
||
|
|
export function artifactPath(value: unknown): string {
|
||
|
|
const path = text(value, "artifact path");
|
||
|
|
if (
|
||
|
|
path.startsWith("/") ||
|
||
|
|
path.includes("\\") ||
|
||
|
|
path.split("/").some((part) => !part || part === "." || part === "..") ||
|
||
|
|
/[\u0000-\u001f\u007f?#%]/.test(path)
|
||
|
|
)
|
||
|
|
fail(path, "Expected a normalized relative artifact path");
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
/** No self-reference: the digest covers ABI/export metadata and the digests of every declared file. */
|
||
|
|
export function implementationDigest(implementation: Omit<ArtifactImplementation, "contentDigest">): string {
|
||
|
|
return hexDigest({
|
||
|
|
...implementation,
|
||
|
|
exports: [...implementation.exports].sort(),
|
||
|
|
files: [...implementation.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||
|
|
const record = object(value, "manifest");
|
||
|
|
keys(
|
||
|
|
record,
|
||
|
|
["schemaVersion", "packageRevisionId", "bindingDigest", "sourceDigest", "versions", "implementations"],
|
||
|
|
[],
|
||
|
|
"manifest",
|
||
|
|
);
|
||
|
|
if (record.schemaVersion !== 1) fail("manifest", "Unsupported artifact manifest version");
|
||
|
|
const versions = object(record.versions, "versions");
|
||
|
|
keys(versions, ["value", "reference", "ports", "crdt"], [], "versions");
|
||
|
|
for (const [key, value] of Object.entries(versions)) text(value, `versions.${key}`);
|
||
|
|
const implementations = array(record.implementations, "implementations").map(
|
||
|
|
(value, index): ArtifactImplementation => {
|
||
|
|
const path = `implementations[${index}]`,
|
||
|
|
entry = object(value, path);
|
||
|
|
keys(
|
||
|
|
entry,
|
||
|
|
["id", "target", "compatibility", "path", "ports", "exports", "files", "contentDigest"],
|
||
|
|
["registryExport"],
|
||
|
|
path,
|
||
|
|
);
|
||
|
|
if (entry.compatibility !== "quixos-service-v1" && entry.compatibility !== "quixos-js-module-v1")
|
||
|
|
fail(path, "Unsupported artifact ABI");
|
||
|
|
if (entry.ports !== "quixos-transaction-ports-v1") fail(path, "Unsupported transaction port ABI");
|
||
|
|
if (
|
||
|
|
entry.compatibility === "quixos-service-v1" &&
|
||
|
|
(entry.target !== "server" || entry.registryExport !== undefined)
|
||
|
|
)
|
||
|
|
fail(path, "Service must target server and has no JS registry export");
|
||
|
|
if (entry.compatibility === "quixos-js-module-v1") text(entry.registryExport, `${path}.registryExport`);
|
||
|
|
const exports = array(entry.exports, `${path}.exports`).map((value) => text(value, `${path}.exports`));
|
||
|
|
unique(exports, `${path}.exports`);
|
||
|
|
const files = array(entry.files, `${path}.files`).map((value): ArtifactFile => {
|
||
|
|
const file = object(value, `${path}.file`);
|
||
|
|
keys(file, ["path", "digest", "bytes", "mediaType"], [], `${path}.file`);
|
||
|
|
if (
|
||
|
|
!Number.isSafeInteger(file.bytes) ||
|
||
|
|
(file.bytes as number) < 0 ||
|
||
|
|
(file.bytes as number) > 256 * 1024 * 1024
|
||
|
|
)
|
||
|
|
fail(path, "Artifact file exceeds size bound");
|
||
|
|
return {
|
||
|
|
path: artifactPath(file.path),
|
||
|
|
digest: digest(file.digest, path),
|
||
|
|
bytes: file.bytes as number,
|
||
|
|
mediaType: text(file.mediaType, path),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
unique(
|
||
|
|
files.map((file) => file.path),
|
||
|
|
`${path}.files`,
|
||
|
|
);
|
||
|
|
const entryPath = artifactPath(entry.path);
|
||
|
|
if (!files.some((file) => file.path === entryPath)) fail(path, "Entrypoint is not covered by the file manifest");
|
||
|
|
const result: ArtifactImplementation = {
|
||
|
|
id: text(entry.id, path),
|
||
|
|
target: text(entry.target, path),
|
||
|
|
compatibility: entry.compatibility as ArtifactImplementation["compatibility"],
|
||
|
|
path: entryPath,
|
||
|
|
ports: "quixos-transaction-ports-v1",
|
||
|
|
exports: exports.sort(),
|
||
|
|
files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
||
|
|
contentDigest: digest(entry.contentDigest, path),
|
||
|
|
...(entry.registryExport !== undefined ? { registryExport: entry.registryExport as string } : {}),
|
||
|
|
};
|
||
|
|
const { contentDigest, ...body } = result;
|
||
|
|
if (implementationDigest(body) !== contentDigest) fail(path, "Implementation metadata digest mismatch");
|
||
|
|
return result;
|
||
|
|
},
|
||
|
|
);
|
||
|
|
unique(
|
||
|
|
implementations.map((implementation) => implementation.id),
|
||
|
|
"implementations",
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
schemaVersion: 1,
|
||
|
|
packageRevisionId: text(record.packageRevisionId, "packageRevisionId"),
|
||
|
|
bindingDigest: digest(record.bindingDigest, "bindingDigest"),
|
||
|
|
sourceDigest: digest(record.sourceDigest, "sourceDigest"),
|
||
|
|
versions: versions as unknown as PackageArtifacts["versions"],
|
||
|
|
implementations,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void {
|
||
|
|
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
|
||
|
|
fail(expected.revision, "Substituted binding schema or source revision");
|
||
|
|
for (const implementation of manifest.implementations)
|
||
|
|
for (const id of implementation.exports) {
|
||
|
|
const entry = expected.exports.find((e) => e.id === id);
|
||
|
|
if (!entry) fail(implementation.id, `Unknown export ${id}`);
|
||
|
|
if (
|
||
|
|
implementation.target !== "server" &&
|
||
|
|
(!entry?.execution ||
|
||
|
|
entry.execution.kind === "remote" ||
|
||
|
|
!entry.execution.targets.includes(implementation.target))
|
||
|
|
)
|
||
|
|
fail(implementation.id, `Export ${id} does not promise target ${implementation.target}`);
|
||
|
|
}
|
||
|
|
for (const entry of expected.exports) {
|
||
|
|
if (!manifest.implementations.some((i) => i.compatibility === "quixos-service-v1" && i.exports.includes(entry.id)))
|
||
|
|
fail(expected.revision, `Missing service fallback for ${entry.id}`);
|
||
|
|
if (entry.execution.kind !== "remote")
|
||
|
|
for (const target of entry.execution.targets)
|
||
|
|
if (
|
||
|
|
!manifest.implementations.some(
|
||
|
|
(i) =>
|
||
|
|
i.target === target &&
|
||
|
|
i.exports.includes(entry.id) &&
|
||
|
|
(target === "server" || i.compatibility !== "quixos-service-v1"),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
fail(expected.revision, `Missing ${target} implementation for ${entry.id}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
export type ArtifactReader = (packageRevisionId: string, path: string, maxBytes: number) => Promise<Uint8Array>;
|
||
|
|
/** Caller supplies a reader confined to the resolved immutable package output, including symlink checks. */
|
||
|
|
export async function checkExecutionWorld(
|
||
|
|
plan: ExecutionPlan,
|
||
|
|
inputs: readonly unknown[],
|
||
|
|
read: ArtifactReader,
|
||
|
|
): Promise<CheckedWorld> {
|
||
|
|
if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions))
|
||
|
|
fail("world", "Unsupported plan codec versions");
|
||
|
|
if (hexDigest(plan.definition) !== plan.digest) fail("plan", "Plan digest mismatch");
|
||
|
|
const manifests = inputs.map(parsePackageArtifacts);
|
||
|
|
unique(
|
||
|
|
manifests.map((m) => m.packageRevisionId),
|
||
|
|
"packages",
|
||
|
|
);
|
||
|
|
if (manifests.length !== plan.definition.packages.length) fail("world", "Package closure differs from plan");
|
||
|
|
const artifacts: CheckedWorld["artifacts"] = [];
|
||
|
|
for (const expected of plan.definition.packages) {
|
||
|
|
const manifest =
|
||
|
|
manifests.find((m) => m.packageRevisionId === expected.revision) ??
|
||
|
|
fail(expected.revision, "Missing package artifact");
|
||
|
|
checkCoverage(expected, manifest);
|
||
|
|
if (canonicalJson(manifest.versions) !== canonicalJson(plan.definition.versions))
|
||
|
|
fail(expected.revision, "Incompatible runtime codecs");
|
||
|
|
const files = new Map<string, ArtifactFile>();
|
||
|
|
for (const implementation of manifest.implementations) {
|
||
|
|
for (const file of implementation.files) {
|
||
|
|
const existing = files.get(file.path);
|
||
|
|
if (existing && canonicalJson(existing) !== canonicalJson(file))
|
||
|
|
fail(file.path, "Conflicting file descriptors");
|
||
|
|
files.set(file.path, file);
|
||
|
|
}
|
||
|
|
artifacts.push({
|
||
|
|
binding: JSON.stringify([expected.revision, implementation.id]),
|
||
|
|
target: implementation.target,
|
||
|
|
digest: implementation.contentDigest,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
for (const file of files.values()) {
|
||
|
|
const bytes = await read(expected.revision, file.path, file.bytes);
|
||
|
|
if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest)
|
||
|
|
fail(file.path, "Artifact bytes do not match the checked content digest");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
codec: "camino-world-v1",
|
||
|
|
plan: plan.digest,
|
||
|
|
artifacts: artifacts.sort((a, b) =>
|
||
|
|
a.binding < b.binding ? -1 : a.binding > b.binding ? 1 : a.target < b.target ? -1 : a.target > b.target ? 1 : 0,
|
||
|
|
),
|
||
|
|
versions: { ...plan.definition.versions },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/** Node counterpart of replica-core worldDigest. The wire fixture checks cross-package agreement. */
|
||
|
|
export function checkedWorldDigest(world: CheckedWorld): string {
|
||
|
|
const normalized = {
|
||
|
|
...world,
|
||
|
|
artifacts: [...world.artifacts].sort((a, b) =>
|
||
|
|
a.binding < b.binding ? -1 : a.binding > b.binding ? 1 : a.target < b.target ? -1 : a.target > b.target ? 1 : 0,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
const json = canonicalJson(normalized),
|
||
|
|
content = createHash("sha256").update(json).digest("hex");
|
||
|
|
const components = ["camino-world-v1", content].map((text) => Buffer.from(text, "utf8"));
|
||
|
|
const count = Buffer.alloc(4);
|
||
|
|
count.writeUInt32BE(components.length);
|
||
|
|
const pieces = [count];
|
||
|
|
for (const bytes of components) {
|
||
|
|
const length = Buffer.alloc(4);
|
||
|
|
length.writeUInt32BE(bytes.length);
|
||
|
|
pieces.push(length, bytes);
|
||
|
|
}
|
||
|
|
return createHash("sha256").update(Buffer.concat(pieces)).digest("hex");
|
||
|
|
}
|