Files
workinf_Blender_Wasm/web/protocol/library-operation-identity.ts
mes123456 5a11045ca5
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance Blender 5.2 web parity through M12-03D
2026-08-17 17:30:27 -04:00

293 lines
13 KiB
TypeScript

import type { ErrorCode } from "./error";
export const LIBRARY_OPERATION_IDENTITY_SCHEMA = 1 as const;
export const LIBRARY_OPERATION_IDENTITY_BUDGET = Object.freeze({
maxSourceLocatorBytes: 4_096,
maxDataBlockIdBytes: 1_024,
maxOwnerIdBytes: 1_024,
});
export type LibraryOperation = "APPEND" | "LINK" | "LIBRARY_OVERRIDE";
export interface LibrarySourceIdentityIR {
schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA;
sourceLibraryId: string;
sourceLocator: string;
sourceSha256: string;
}
export interface LocalMainOwnerIR {
kind: "LOCAL_MAIN";
projectId: string;
localDataBlockId: string;
}
export interface SourceLibraryOwnerIR {
kind: "SOURCE_LIBRARY";
sourceLibraryId: string;
sourceDataBlockId: string;
}
export interface LocalOverrideOwnerIR {
kind: "LOCAL_OVERRIDE";
projectId: string;
localDataBlockId: string;
referenceSourceDataBlockId: string;
hierarchyRootDataBlockId: string;
}
export type LibraryOperationOwnerIR = LocalMainOwnerIR | SourceLibraryOwnerIR | LocalOverrideOwnerIR;
export interface LibraryOperationBindingIR {
schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA;
operation: LibraryOperation;
source: LibrarySourceIdentityIR;
sourceDataBlockId: string;
owner: LibraryOperationOwnerIR;
readOnly: boolean;
referenceReadOnly: boolean;
sourceGeneration: number;
sourceRevision: number;
dependencyClosureSha256: string;
invalidationToken: string;
}
export interface LibrarySourceStateIR {
sourceLibraryId: string;
sourceSha256: string;
sourceGeneration: number;
sourceRevision: number;
dependencyClosureSha256: string;
}
export class LibraryOperationIdentityError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "LibraryOperationIdentityError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
const INVALIDATION_TOKEN = /^libtoken:[a-f0-9]{64}$/;
const encoder = new TextEncoder();
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
const keys = new Set(allowed);
if (Object.keys(value).some((key) => !keys.has(key))) {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`);
}
}
function text(value: unknown, path: string, maximumBytes: number): string {
if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximumBytes) {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} is outside its UTF-8 byte budget`);
}
return value;
}
function digest(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`);
}
return value;
}
function integer(value: unknown, path: string, minimum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a safe integer >= ${minimum}`);
}
return value;
}
function stableJSON(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
if (record(value)) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
async function sha256(value: string): Promise<string> {
const result = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function computeLibrarySourceId(value: { sourceLocator: string; sourceSha256: string }): Promise<string> {
return `library:${await sha256(stableJSON({
schema: "BLENDER_LIBRARY_SOURCE_V1",
sourceLocator: value.sourceLocator,
sourceSha256: value.sourceSha256,
}))}`;
}
export async function createLibrarySourceIdentity(value: {
sourceLocator: string;
sourceSha256: string;
}): Promise<LibrarySourceIdentityIR> {
const sourceLocator = text(value.sourceLocator, "sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes);
const sourceSha256 = digest(value.sourceSha256, "sourceSha256");
return {
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
sourceLibraryId: await computeLibrarySourceId({ sourceLocator, sourceSha256 }),
sourceLocator,
sourceSha256,
};
}
export async function parseLibrarySourceIdentity(value: unknown): Promise<LibrarySourceIdentityIR> {
if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) {
throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library source identity schema");
}
exactKeys(value, ["schemaVersion", "sourceLibraryId", "sourceLocator", "sourceSha256"], "source");
const sourceLocator = text(value.sourceLocator, "source.sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes);
const sourceSha256 = digest(value.sourceSha256, "source.sourceSha256");
if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId) ||
value.sourceLibraryId !== await computeLibrarySourceId({ sourceLocator, sourceSha256 })) {
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "sourceLibraryId does not bind locator and source SHA-256");
}
return {
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
sourceLibraryId: value.sourceLibraryId,
sourceLocator,
sourceSha256,
};
}
function parseOwner(value: unknown, operation: LibraryOperation, source: LibrarySourceIdentityIR, sourceDataBlockId: string): LibraryOperationOwnerIR {
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "owner must be an object");
if (operation === "APPEND") {
exactKeys(value, ["kind", "projectId", "localDataBlockId"], "owner");
if (value.kind !== "LOCAL_MAIN") throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "APPEND owner must be LOCAL_MAIN");
return {
kind: "LOCAL_MAIN",
projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes),
localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
};
}
if (operation === "LINK") {
exactKeys(value, ["kind", "sourceLibraryId", "sourceDataBlockId"], "owner");
if (value.kind !== "SOURCE_LIBRARY" || value.sourceLibraryId !== source.sourceLibraryId || value.sourceDataBlockId !== sourceDataBlockId) {
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LINK owner must match the source library and data-block");
}
return { kind: "SOURCE_LIBRARY", sourceLibraryId: source.sourceLibraryId, sourceDataBlockId };
}
exactKeys(value, ["kind", "projectId", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId"], "owner");
if (value.kind !== "LOCAL_OVERRIDE" || value.referenceSourceDataBlockId !== sourceDataBlockId) {
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LIBRARY_OVERRIDE owner must retain the source reference");
}
return {
kind: "LOCAL_OVERRIDE",
projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes),
localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
referenceSourceDataBlockId: sourceDataBlockId,
hierarchyRootDataBlockId: text(value.hierarchyRootDataBlockId, "owner.hierarchyRootDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
};
}
type LibraryOperationBindingInput = Omit<LibraryOperationBindingIR, "invalidationToken">;
function assertOwnershipSemantics(operation: LibraryOperation, readOnly: boolean, referenceReadOnly: boolean): void {
const valid = operation === "APPEND"
? !readOnly && !referenceReadOnly
: operation === "LINK"
? readOnly && referenceReadOnly
: !readOnly && referenceReadOnly;
if (!valid) {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${operation} read-only semantics are invalid`);
}
}
async function parseBindingBase(value: unknown): Promise<LibraryOperationBindingInput> {
if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) {
throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library operation binding schema");
}
const operation = value.operation;
if (operation !== "APPEND" && operation !== "LINK" && operation !== "LIBRARY_OVERRIDE") {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "operation is invalid");
}
const source = await parseLibrarySourceIdentity(value.source);
const sourceDataBlockId = text(value.sourceDataBlockId, "sourceDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes);
if (typeof value.readOnly !== "boolean" || typeof value.referenceReadOnly !== "boolean") {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "read-only fields must be boolean");
}
assertOwnershipSemantics(operation, value.readOnly, value.referenceReadOnly);
return {
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
operation,
source,
sourceDataBlockId,
owner: parseOwner(value.owner, operation, source, sourceDataBlockId),
readOnly: value.readOnly,
referenceReadOnly: value.referenceReadOnly,
sourceGeneration: integer(value.sourceGeneration, "sourceGeneration", 1),
sourceRevision: integer(value.sourceRevision, "sourceRevision", 0),
dependencyClosureSha256: digest(value.dependencyClosureSha256, "dependencyClosureSha256"),
};
}
export async function computeLibraryInvalidationToken(value: LibraryOperationBindingInput): Promise<string> {
return `libtoken:${await sha256(stableJSON(value))}`;
}
export async function createLibraryOperationBinding(value: LibraryOperationBindingInput): Promise<LibraryOperationBindingIR> {
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object");
exactKeys(value, [
"schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly",
"referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256",
], "binding");
const parsed = await parseBindingBase(value);
return { ...parsed, invalidationToken: await computeLibraryInvalidationToken(parsed) };
}
export async function parseLibraryOperationBinding(value: unknown): Promise<LibraryOperationBindingIR> {
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object");
exactKeys(value, [
"schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly",
"referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256",
"invalidationToken",
], "binding");
const parsed = await parseBindingBase(value);
if (typeof value.invalidationToken !== "string" || !INVALIDATION_TOKEN.test(value.invalidationToken) ||
value.invalidationToken !== await computeLibraryInvalidationToken(parsed)) {
throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library operation invalidation token is stale or forged");
}
return { ...parsed, invalidationToken: value.invalidationToken };
}
function parseSourceState(value: unknown): LibrarySourceStateIR {
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state must be an object");
exactKeys(value, ["sourceLibraryId", "sourceSha256", "sourceGeneration", "sourceRevision", "dependencyClosureSha256"], "source state");
if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId)) {
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state library ID is invalid");
}
return {
sourceLibraryId: value.sourceLibraryId,
sourceSha256: digest(value.sourceSha256, "source state sourceSha256"),
sourceGeneration: integer(value.sourceGeneration, "source state sourceGeneration", 1),
sourceRevision: integer(value.sourceRevision, "source state sourceRevision", 0),
dependencyClosureSha256: digest(value.dependencyClosureSha256, "source state dependencyClosureSha256"),
};
}
export async function assertLibraryOperationBindingCurrent(
bindingValue: unknown,
sourceStateValue: unknown,
): Promise<LibraryOperationBindingIR> {
const binding = await parseLibraryOperationBinding(bindingValue);
const state = parseSourceState(sourceStateValue);
if (binding.source.sourceLibraryId !== state.sourceLibraryId || binding.source.sourceSha256 !== state.sourceSha256 ||
binding.sourceGeneration !== state.sourceGeneration || binding.sourceRevision !== state.sourceRevision ||
binding.dependencyClosureSha256 !== state.dependencyClosureSha256) {
throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library source or dependency closure has been invalidated");
}
return binding;
}