198 lines
8.5 KiB
TypeScript
198 lines
8.5 KiB
TypeScript
export const RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION = 1 as const;
|
|
export const RENDER_COMPOSITOR_MEDIA_DOMAINS = ["RENDER", "COMPOSITOR", "MEDIA"] as const;
|
|
export type RenderCompositorMediaDomain = typeof RENDER_COMPOSITOR_MEDIA_DOMAINS[number];
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const CANCELLATION_CODES = {
|
|
RENDER: "OPEN_CANCELLED",
|
|
COMPOSITOR: "COMPOSITOR_CANCELLED",
|
|
MEDIA: "SEQUENCER_CANCELLED",
|
|
} as const;
|
|
const BUDGET_CODES = {
|
|
RENDER: "GPU_TEXTURE_BUDGET_EXCEEDED",
|
|
COMPOSITOR: "COMPOSITOR_BUDGET_EXCEEDED",
|
|
MEDIA: "SEQUENCER_BUDGET_EXCEEDED",
|
|
} as const;
|
|
|
|
export interface RenderCompositorMediaRecoveryEvidenceIR {
|
|
schemaVersion: typeof RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION;
|
|
domain: RenderCompositorMediaDomain;
|
|
source: {
|
|
byteLength: number;
|
|
sha256: string;
|
|
};
|
|
cancellation: {
|
|
status: "CANCELLED";
|
|
code: typeof CANCELLATION_CODES[RenderCompositorMediaDomain];
|
|
publishedResults: 0;
|
|
temporaryResourcesAfter: 0;
|
|
};
|
|
restart: {
|
|
status: "RECOVERED";
|
|
generationBefore: 1;
|
|
generationAfter: 2;
|
|
identityBefore: string;
|
|
identityAfter: string;
|
|
outputSha256Before: string;
|
|
outputSha256After: string;
|
|
};
|
|
budget: {
|
|
status: "BLOCKED";
|
|
code: typeof BUDGET_CODES[RenderCompositorMediaDomain];
|
|
retainedIdentityHash: string;
|
|
temporaryResourcesAfter: 0;
|
|
};
|
|
release: {
|
|
status: "RELEASED";
|
|
releasedBytes: number;
|
|
releasedResources: number;
|
|
resourcesAfter: 0;
|
|
};
|
|
recovery: {
|
|
status: "RECOVERED";
|
|
identityHash: string;
|
|
outputSha256: string;
|
|
outputBytes: number;
|
|
visibleUnits: number;
|
|
};
|
|
}
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error(`M11_RECOVERY_INVALID: ${label} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(value: Record<string, unknown>, fields: readonly string[], label: string): void {
|
|
const allowed = new Set(fields);
|
|
const extra = Object.keys(value).find((field) => !allowed.has(field));
|
|
if (extra) throw new Error(`M11_RECOVERY_INVALID: ${label}.${extra} is undeclared`);
|
|
}
|
|
|
|
function integer(value: unknown, label: string, minimum = 0): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
|
throw new Error(`M11_RECOVERY_INVALID: ${label} must be a safe integer >= ${minimum}`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function digest(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
throw new Error(`M11_RECOVERY_INVALID: ${label} must be a SHA-256 digest`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function zero(value: unknown, label: string): 0 {
|
|
if (value !== 0) throw new Error(`M11_RECOVERY_INVALID: ${label} must be zero`);
|
|
return 0;
|
|
}
|
|
|
|
export function parseRenderCompositorMediaRecoveryEvidence(value: unknown): RenderCompositorMediaRecoveryEvidenceIR {
|
|
const input = record(value, "evidence");
|
|
exact(input, ["schemaVersion", "domain", "source", "cancellation", "restart", "budget", "release", "recovery"], "evidence");
|
|
if (input.schemaVersion !== RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION) {
|
|
throw new Error("M11_RECOVERY_INVALID: unsupported schema version");
|
|
}
|
|
if (!RENDER_COMPOSITOR_MEDIA_DOMAINS.includes(input.domain as RenderCompositorMediaDomain)) {
|
|
throw new Error("M11_RECOVERY_INVALID: domain is invalid");
|
|
}
|
|
const domain = input.domain as RenderCompositorMediaDomain;
|
|
|
|
const source = record(input.source, "source");
|
|
exact(source, ["byteLength", "sha256"], "source");
|
|
const sourceByteLength = integer(source.byteLength, "source.byteLength", 1);
|
|
const sourceSha256 = digest(source.sha256, "source.sha256");
|
|
|
|
const cancellation = record(input.cancellation, "cancellation");
|
|
exact(cancellation, ["status", "code", "publishedResults", "temporaryResourcesAfter"], "cancellation");
|
|
if (cancellation.status !== "CANCELLED" || cancellation.code !== CANCELLATION_CODES[domain]) {
|
|
throw new Error(`M11_RECOVERY_INVALID: ${domain} cancellation contract is invalid`);
|
|
}
|
|
zero(cancellation.publishedResults, "cancellation.publishedResults");
|
|
zero(cancellation.temporaryResourcesAfter, "cancellation.temporaryResourcesAfter");
|
|
|
|
const restart = record(input.restart, "restart");
|
|
exact(restart, ["status", "generationBefore", "generationAfter", "identityBefore", "identityAfter", "outputSha256Before", "outputSha256After"], "restart");
|
|
if (restart.status !== "RECOVERED" || restart.generationBefore !== 1 || restart.generationAfter !== 2) {
|
|
throw new Error("M11_RECOVERY_INVALID: restart must advance exactly from generation 1 to 2");
|
|
}
|
|
const identityBefore = digest(restart.identityBefore, "restart.identityBefore");
|
|
const identityAfter = digest(restart.identityAfter, "restart.identityAfter");
|
|
const outputSha256Before = digest(restart.outputSha256Before, "restart.outputSha256Before");
|
|
const outputSha256After = digest(restart.outputSha256After, "restart.outputSha256After");
|
|
if (identityBefore !== identityAfter || outputSha256Before !== outputSha256After) {
|
|
throw new Error("M11_RECOVERY_INVALID: restart changed committed identity or output");
|
|
}
|
|
|
|
const budget = record(input.budget, "budget");
|
|
exact(budget, ["status", "code", "retainedIdentityHash", "temporaryResourcesAfter"], "budget");
|
|
if (budget.status !== "BLOCKED" || budget.code !== BUDGET_CODES[domain]) {
|
|
throw new Error(`M11_RECOVERY_INVALID: ${domain} budget contract is invalid`);
|
|
}
|
|
const retainedIdentityHash = digest(budget.retainedIdentityHash, "budget.retainedIdentityHash");
|
|
if (retainedIdentityHash !== identityAfter) {
|
|
throw new Error("M11_RECOVERY_INVALID: budget failure changed committed identity");
|
|
}
|
|
zero(budget.temporaryResourcesAfter, "budget.temporaryResourcesAfter");
|
|
|
|
const release = record(input.release, "release");
|
|
exact(release, ["status", "releasedBytes", "releasedResources", "resourcesAfter"], "release");
|
|
if (release.status !== "RELEASED") throw new Error("M11_RECOVERY_INVALID: release.status");
|
|
const releasedBytes = integer(release.releasedBytes, "release.releasedBytes", 1);
|
|
const releasedResources = integer(release.releasedResources, "release.releasedResources", 1);
|
|
zero(release.resourcesAfter, "release.resourcesAfter");
|
|
|
|
const recovery = record(input.recovery, "recovery");
|
|
exact(recovery, ["status", "identityHash", "outputSha256", "outputBytes", "visibleUnits"], "recovery");
|
|
if (recovery.status !== "RECOVERED") throw new Error("M11_RECOVERY_INVALID: recovery.status");
|
|
const recoveryIdentity = digest(recovery.identityHash, "recovery.identityHash");
|
|
const recoveryOutput = digest(recovery.outputSha256, "recovery.outputSha256");
|
|
if (recoveryIdentity !== identityAfter || recoveryOutput !== outputSha256After) {
|
|
throw new Error("M11_RECOVERY_INVALID: recovered identity or output does not match generation 2");
|
|
}
|
|
const outputBytes = integer(recovery.outputBytes, "recovery.outputBytes", 1);
|
|
const visibleUnits = integer(recovery.visibleUnits, "recovery.visibleUnits", 1);
|
|
|
|
return {
|
|
schemaVersion: RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION,
|
|
domain,
|
|
source: { byteLength: sourceByteLength, sha256: sourceSha256 },
|
|
cancellation: {
|
|
status: "CANCELLED",
|
|
code: CANCELLATION_CODES[domain],
|
|
publishedResults: 0,
|
|
temporaryResourcesAfter: 0,
|
|
},
|
|
restart: {
|
|
status: "RECOVERED",
|
|
generationBefore: 1,
|
|
generationAfter: 2,
|
|
identityBefore,
|
|
identityAfter,
|
|
outputSha256Before,
|
|
outputSha256After,
|
|
},
|
|
budget: {
|
|
status: "BLOCKED",
|
|
code: BUDGET_CODES[domain],
|
|
retainedIdentityHash,
|
|
temporaryResourcesAfter: 0,
|
|
},
|
|
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
|
recovery: { status: "RECOVERED", identityHash: recoveryIdentity, outputSha256: recoveryOutput, outputBytes, visibleUnits },
|
|
};
|
|
}
|
|
|
|
export function parseRenderCompositorMediaRecoverySuite(value: unknown): RenderCompositorMediaRecoveryEvidenceIR[] {
|
|
if (!Array.isArray(value) || value.length !== RENDER_COMPOSITOR_MEDIA_DOMAINS.length) {
|
|
throw new Error("M11_RECOVERY_INVALID: suite must contain exactly three domains");
|
|
}
|
|
const reports = value.map(parseRenderCompositorMediaRecoveryEvidence);
|
|
if (new Set(reports.map((report) => report.domain)).size !== RENDER_COMPOSITOR_MEDIA_DOMAINS.length) {
|
|
throw new Error("M11_RECOVERY_INVALID: suite contains duplicate domains");
|
|
}
|
|
return RENDER_COMPOSITOR_MEDIA_DOMAINS.map((domain) => reports.find((report) => report.domain === domain)!);
|
|
}
|